Compare commits

..
36 Commits
Author SHA1 Message Date
Nicolò Boschi 23484fc9a7 ci: add upgrade tests 2026-01-26 14:13:13 +01:00
Nicolò Boschi 31b5c5845d chore: versioned docs (#198) 2026-01-26 11:21:13 +01:00
c0ca9b027e Fix: Pass api_key to Hindsight client in litellm integration (#193)
* Fix: Pass api_key to Hindsight client in litellm integration

The recall(), reflect(), and retain() wrapper functions were creating
Hindsight client instances without passing the api_key from the config.
This caused 401 Unauthorized errors when using hindsight-litellm with
authenticated Hindsight API servers.

Also added api_key parameter to:
- HindsightOpenAI and HindsightAnthropic wrapper classes
- wrap_openai() and wrap_anthropic() functions

* Add sensible defaults for simpler API usage

Make it easier to get started with hindsight-litellm by providing
sensible defaults:

- Default API URL: https://api.hindsight.vectorize.io (production)
- Default bank_id: "default"
- Read api_key from HINDSIGHT_API_KEY environment variable

Now users can simply do:

    client = wrap_openai(OpenAI())

With just the HINDSIGHT_API_KEY env var set, and it works.

Also adds comprehensive unit tests for the new defaults behavior.

* Fix test using non-existent 'enabled' parameter in configure()

The test was calling configure(enabled=False) but configure() doesn't
have an enabled parameter. Changed to test is_configured() returns False
when reset_config() has been called.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Fix: rename 'background' parameter to 'mission' in Python client create_bank()

The parameter was named 'background' but the internal code used 'mission',
causing undefined variable errors. The tests also expected 'mission'.

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: Nicolò Boschi <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-26 10:49:06 +01:00
1d4879a206 feat(litellm): async retain, reflect support, and API cleanup (#167)
* feat(litellm): async retain with sync option, fix client session cleanup

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

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

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Remove deprecated background parameter and legacy configure() parameters

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-01-26 10:07:32 +01:00
Nicolò Boschi 8e39cb7bc8 fix: improve mental model consolidation (#197)
* fix: improve mental model consolidation

* fix skill names

* fixes

* fix: add missing list_tenants to test mocks and update CLI for async refresh
2026-01-26 09:54:25 +01:00
Nicolò Boschi b378f6852f feat(mcp): add timestamp to retain (#190)
* feat(mcp): add timestamp to retain

* ci
2026-01-23 16:00:43 +01:00
Nicolò Boschi 9c2df9d89f fix skill names 2026-01-23 11:03:12 +01:00
Nicolò Boschi ec2231799e feat: support for npx add-skill (#191)
* feat: support for npx add-skill

* skills
2026-01-23 11:01:53 +01:00
Phạm Gia Linh aebef9408b feat(python-sdk): add tags filtering support to high-level client (#186)
Add tags and tags_match parameters to recall/reflect methods for
filtering
memories by visibility scope. Also add tags support to retain methods.

Changes:
- recall()/arecall(): add tags, tags_match parameters
- reflect()/areflect(): add tags, tags_match parameters
- retain()/aretain(): add tags parameter
- retain_batch()/aretain_batch(): add document_tags parameter
- Add TestTags test class with 7 tests
2026-01-23 10:02:05 +01:00
Chris Bartholomew 66abad61b8 Fix Gemini tool response format by including function name (#187)
Gemini requires the 'name' field in tool/function response messages,
while OpenAI infers it from tool_call_id. Without it, Gemini returns:
  'function_response.name: Name cannot be empty'

Added 'name' field to both tool result messages in the reflect agent.
2026-01-23 07:38:51 +01:00
Nicolò Boschi 9db64ecda3 feat: revisit mental models, directives and reflections (#179)
* chore: run benchmarks with reflect mode

* chore: run benchmarks with reflect mode

* fixes

* new mm

* bunch of fixes

* initial commit

* fixes

* fixes

* fixes

* fix: sometimes memories gets extracted in the wrong language
2026-01-22 17:13:16 +01:00
Nicolò Boschi ddaa5f5f1b fix: simplify pytorch model initialization to prevent meta tensor issues (#185)
Remove device_map from model_kwargs as it conflicts with CrossEncoder's
internal .to(device) call. The low_cpu_mem_usage=False setting alone is
sufficient to prevent lazy loading (meta tensors).
2026-01-22 16:25:28 +01:00
Nicolò Boschi 87d4a36509 fix: sometimes memories gets extracted in the wrong language (#184) 2026-01-22 14:21:59 +01:00
Nicolò Boschi 0bf85a3435 fix: improve pytorch model initialization to prevent meta tensor issues (#180)
* fix: prevent meta tensor issues when accelerate is installed without GPU

When accelerate is installed but no GPU is available, transformers can
incorrectly use lazy loading (meta tensors) which fails when
sentence-transformers tries to move the model to a device.

The fix checks hardware and installed packages to determine the right
loading strategy:
- GPU available: device=None, device_map=None (auto-detect GPU)
- No GPU + accelerate: device='cpu', device_map='cpu' (force CPU loading)
- No GPU + no accelerate: device='cpu', device_map=None (normal CPU)

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

* fix: add filelock for model initialization in parallel tests

When pytest-xdist runs multiple workers in parallel, they all try to
load models from the HuggingFace cache simultaneously, causing race
conditions and intermittent meta tensor errors.

Added filelock around embeddings and cross_encoder initialization in
conftest.py, similar to how pg0 database setup is serialized. Models
are now pre-initialized in the fixture before being passed to tests.

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

* fix: add MPS support for macOS Apple Silicon

Extend GPU detection to include Apple MPS backend in addition to CUDA.
This ensures macOS users with Apple Silicon use MPS acceleration
instead of being incorrectly routed to the CPU fallback path.
2026-01-20 14:15:51 +01:00
Nicolò Boschi 16b85a4faa chore: drop unused access_count column (#178) 2026-01-20 10:36:02 +01:00
Nicolò Boschi 4c792400c1 feat: new 'worker' service (#176)
* feat: new 'worker' service

* doc

* docs

* tests
2026-01-20 10:17:56 +01:00
Nicolò Boschi 0284595909 fix: pytorch init failures (#175) 2026-01-19 15:02:20 +01:00
Nicolò Boschi fe4ed1db73 feat(clients): mental models api (#172)
* feat(clients): mental models api

* fixes

* more tests

* fixes
2026-01-19 14:49:49 +01:00
Nicolò Boschi bac4b24e30 fix(sec): upgrade vulnerable deps (#174) 2026-01-19 14:27:31 +01:00
Nicolò Boschi 3290f4bfff chore: unify agents.md and claude.md (#173) 2026-01-19 14:18:33 +01:00
Nicolò Boschi 63a65d0723 feat: improve mental model refresh and add directives (#166)
* feat: improve mental model refresh and add directives

* feat: improve mental model refresh and add directives

* tags

* ui

* fix

* fix

* update

* update
2026-01-19 11:38:35 +01:00
Chris Bartholomew 870cfccabb Add structured JSON logging support (#170)
* Add structured JSON logging support

Add HINDSIGHT_API_LOG_FORMAT environment variable to configure log output
format. Options are "text" (default, human-readable) and "json" (structured).

JSON format outputs logs with a "severity" field that cloud logging systems
can parse for proper log level categorization. Also writes to stdout instead
of stderr so log levels are correctly interpreted.

* Rename GCPJsonFormatter to JsonFormatter
2026-01-19 09:01:24 +01:00
Nicolò Boschi 4476a10aa3 doc: refinement for 0.3.0 new features (#159)
* doc: refinement for 0.3.0 new features

* fix

* fix

* fixes
2026-01-16 11:16:52 +01:00
Nicolò Boschi 4f2833873c feat: introduce mental models (#132)
* mental models

* DRAFT: refactor entity observations

* fix db patch

* agentic

* agentic

* reflect agent

* new style

* more

* fix ci

* fix

* fix
2026-01-16 11:16:41 +01:00
Nicolò Boschi 1eeced3116 feat(cli): accept more file types on retain-files (#163)
* feat(cli): accept more file types on retain-files

* feat(cli): accept more file types on retain-files
2026-01-15 18:34:44 +01:00
Chris Bartholomew 55c216e069 Fix skill installer test examples to use meaningful content (#160)
The "Test memory" example is too short for the LLM to extract
meaningful facts from, causing the test to silently fail (0 memories
created). Replace with "Alice works at Google as a software engineer"
which has enough context for fact extraction.

Fixes test examples in:
- get-skill installer (local and cloud modes)
- hindsight-embed configure output
- skills.md documentation
2026-01-14 18:41:04 +01:00
Chris Bartholomew e64d3634a9 feat: add cloud mode to skill installer for team memory sharing (#158)
* doc: update expired Slack invite link

* feat: add cloud mode to skill installer for team memory sharing

Adds support for Hindsight Cloud in the skill installer, enabling teams
to share memories about a codebase. Changes include:

- Add `--mode cloud` option to get-skill installer
- Install hindsight CLI binary for cloud mode (via get-cli)
- Configure ~/.hindsight/config with API URL and key
- Generate cloud-specific SKILL.md with team-aware guidance
- Distinguish between project conventions and individual preferences
- Update skills.md documentation with cloud setup instructions

Cloud mode workflow:
1. Team admin creates a bank in Hindsight Cloud
2. Each developer runs: curl ... | bash -s -- --mode cloud
3. All team members share the same memory bank
4. Knowledge retained by one member benefits everyone
2026-01-14 09:05:40 +01:00
Chris Bartholomew 70ce979fbe doc: update expired Slack invite link (#157) 2026-01-13 16:57:23 -05:00
Nicolò Boschi de132501c6 doc: changelog for 0.3.0 (#156) 2026-01-13 19:09:13 +01:00
Nicolò Boschi a75dcfebf5 Release v0.3.0
- Update version to 0.3.0 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2026-01-13 18:43:33 +01:00
Nicolò Boschi 20c8f8b06a feat: add memory tags (#152)
* feat: add memory tags

* feat: add memory tags

* support tags

* support tags
2026-01-13 18:28:40 +01:00
Chris Bartholomew f5f3fca4ad Fix: Load extensions in server.py for multi-worker deployments (#155)
* Fix: Load extensions in server.py for multi-worker deployments

When running with multiple workers (--workers 2), uvicorn uses
`hindsight_api.server:app` import string instead of passing an app
object. The server.py module was not loading tenant/operation validator
extensions, causing authentication bypass in production.

This fix:
- Adds extension loading to server.py matching main.py behavior
- Sets extension context on tenant extension for schema provisioning
- Adds comprehensive unit tests for server.py extension loading

The tests specifically verify:
- TENANT extension is loaded when HINDSIGHT_API_TENANT_EXTENSION is set
- OPERATION_VALIDATOR is loaded when configured
- Extensions are passed to MemoryEngine constructor
- Extension context is set on tenant extension
- Server works correctly without extensions configured

* Add unit tests for main.py extension loading (single-worker path)
2026-01-13 17:55:33 +01:00
Nicolò Boschi d47c8a28cc feat: support litellm gateway (#154) 2026-01-13 16:55:28 +01:00
Nicolò Boschi 1ffc2a418c feat: add tenant to metrics labels (#151) 2026-01-13 15:31:58 +01:00
Nicolò Boschi fa53917c63 feat: support custom url for openai embeddings & cohere (#150)
* feat: support custom url for openai embeddings & cohere

* feat: support custom url for openai embeddings & cohere
2026-01-13 14:01:44 +01:00
Nicolò Boschi 59913086be fix: batch queries on recall (#149)
* fix: batch queries on recall

* fix: batch queries on recall
2026-01-13 13:20:22 +01:00
320 changed files with 54052 additions and 5960 deletions
+74 -26
View File
@@ -153,8 +153,15 @@ jobs:
- name: Build docs
run: npm run build --workspace=hindsight-docs
build-rust-cli:
test-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
@@ -171,6 +178,10 @@ 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
@@ -182,29 +193,6 @@ 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:
@@ -251,7 +239,7 @@ jobs:
- name: Run CLI smoke test
run: |
HINDSIGHT_CLI=/tmp/cli/hindsight ./hindsight-cli/smoke-test.sh
HINDSIGHT_CLI=hindsight-cli/target/release/hindsight ./hindsight-cli/smoke-test.sh
- name: Show API server logs
if: always()
@@ -777,7 +765,7 @@ jobs:
test-doc-examples:
runs-on: ubuntu-latest
needs: build-rust-cli
needs: test-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
@@ -887,6 +875,66 @@ 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
verify-generated-files:
runs-on: ubuntu-latest
env:
+1 -1
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
+1 -151
View File
@@ -1,153 +1,3 @@
# AGENTS.md
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
See [CLAUDE.md](./CLAUDE.md) for project documentation and coding conventions.
+51
View File
@@ -174,12 +174,63 @@ This runs the same checks as the pre-commit hook (Ruff for Python, ESLint/Pretti
- Multi-bank queries are client responsibility to orchestrate
- Disposition traits only affect reflect, not recall
### Control Plane API Routes
When adding or modifying parameters in the dataplane API (hindsight-api), you must also update the control plane routes that proxy to it:
1. **API Routes** (`hindsight-control-plane/src/app/api/`):
- `recall/route.ts` - proxies to `/v1/default/banks/{bank_id}/memories/recall`
- `reflect/route.ts` - proxies to `/v1/default/banks/{bank_id}/reflect`
- `memories/retain/route.ts` - proxies to `/v1/default/banks/{bank_id}/memories/retain`
- Other routes follow the same pattern
2. **Client types** (`hindsight-control-plane/src/lib/api.ts`):
- Update the TypeScript type definitions for `recall()`, `reflect()`, `retain()` etc.
3. **Checklist when adding new API parameters**:
- Add parameter extraction in the route handler (destructure from `body`)
- Pass the parameter to the SDK call
- Update the client type definition in `lib/api.ts`
- Update any UI components that need to use the new parameter
### Python Style
- Python 3.11+, type hints required
- Async throughout (asyncpg, async FastAPI)
- 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
+2 -2
View File
@@ -5,7 +5,7 @@
[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)
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
@@ -242,7 +242,7 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
- [CLI](https://hindsight.vectorize.io/sdks/cli)
**Community:**
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)
---
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.2.1
appVersion: "0.2.1"
version: 0.3.0
appVersion: "0.3.0"
keywords:
- ai
- memory
+16
View File
@@ -80,6 +80,22 @@ 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,6 +55,11 @@ 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 }}
@@ -0,0 +1,25 @@
{{- 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 }}
@@ -0,0 +1,110 @@
{{- 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,6 +67,63 @@ 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
+59
View File
@@ -244,6 +244,65 @@ 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()
@@ -0,0 +1,48 @@
"""add_tags_column
Revision ID: g2a3b4c5d6e7
Revises: f1a2b3c4d5e6
Create Date: 2025-01-13
Add tags column to memory_units and documents tables for visibility scoping.
Tags enable filtering memories by scope (e.g., user IDs, session IDs) during recall/reflect.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "g2a3b4c5d6e7"
down_revision: str | Sequence[str] | None = "f1a2b3c4d5e6"
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 (e.g., 'tenant_x.' or '' for public)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add tags column to memory_units and documents tables."""
schema = _get_schema_prefix()
# Add tags column to memory_units table
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS tags VARCHAR[] NOT NULL DEFAULT '{{}}'")
# Create GIN index for efficient array containment queries (tags && ARRAY['x'])
op.execute(f"CREATE INDEX IF NOT EXISTS idx_memory_units_tags ON {schema}memory_units USING GIN (tags)")
# Add tags column to documents table for document-level tags
op.execute(f"ALTER TABLE {schema}documents ADD COLUMN IF NOT EXISTS tags VARCHAR[] NOT NULL DEFAULT '{{}}'")
def downgrade() -> None:
"""Remove tags columns and index."""
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_tags")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS tags")
op.execute(f"ALTER TABLE {schema}documents DROP COLUMN IF EXISTS tags")
@@ -0,0 +1,112 @@
"""mental_models_v4
Revision ID: h3c4d5e6f7g8
Revises: g2a3b4c5d6e7
Create Date: 2026-01-08 00:00:00.000000
This migration implements the v4 mental models system:
1. Deletes existing observation memory_units (observations now in mental models)
2. Adds mission column to banks (replacing background)
3. Creates mental_models table with final schema
Mental models can reference entities when an entity is "promoted" to a mental model.
Summary content is stored as JSONB observations with per-observation fact attribution.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "h3c4d5e6f7g8"
down_revision: str | Sequence[str] | None = "g2a3b4c5d6e7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Apply mental models v4 changes."""
schema = _get_schema_prefix()
# Step 1: Delete observation memory_units (cascades to unit_entities links)
# Observations are now handled through mental models, not memory_units
op.execute(f"DELETE FROM {schema}memory_units WHERE fact_type = 'observation'")
# Step 2: Drop observation-specific index (if it exists)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_observation_date")
# Step 3: Add mission column to banks (replacing background)
op.execute(f"ALTER TABLE {schema}banks ADD COLUMN IF NOT EXISTS mission TEXT")
# Migrate: copy background to mission if background column exists
# Use DO block to check column existence first (idempotent for re-runs)
schema_name = context.config.get_main_option("target_schema") or "public"
op.execute(f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = '{schema_name}' AND table_name = 'banks' AND column_name = 'background'
) THEN
UPDATE {schema}banks
SET mission = background
WHERE mission IS NULL;
END IF;
END $$;
""")
# Remove background column (replaced by mission)
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS background")
# Step 4: Create mental_models table with final v4 schema (if not exists)
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}mental_models (
id VARCHAR(64) NOT NULL,
bank_id VARCHAR(64) NOT NULL,
subtype VARCHAR(32) NOT NULL,
name VARCHAR(256) NOT NULL,
description TEXT NOT NULL,
entity_id UUID,
observations JSONB DEFAULT '{{"observations": []}}'::jsonb,
links VARCHAR[],
tags VARCHAR[] DEFAULT '{{}}',
last_updated TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
PRIMARY KEY (id, bank_id),
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
FOREIGN KEY (entity_id) REFERENCES {schema}entities(id) ON DELETE SET NULL,
CONSTRAINT ck_mental_models_subtype CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'))
)
""")
# Step 5: Create indexes for efficient queries (if not exist)
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_bank_id ON {schema}mental_models(bank_id)")
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_subtype ON {schema}mental_models(bank_id, subtype)")
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_entity_id ON {schema}mental_models(entity_id)")
# GIN index for efficient tags array filtering
op.execute(f"CREATE INDEX IF NOT EXISTS idx_mental_models_tags ON {schema}mental_models USING GIN(tags)")
def downgrade() -> None:
"""Revert mental models v4 changes."""
schema = _get_schema_prefix()
# Drop mental_models table (cascades to indexes)
op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE")
# Add back background column to banks
op.execute(f"ALTER TABLE {schema}banks ADD COLUMN IF NOT EXISTS background TEXT")
# Migrate mission back to background
op.execute(f"UPDATE {schema}banks SET background = mission WHERE background IS NULL")
# Remove mission column
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission")
# Note: Cannot restore deleted observations - they are lost on downgrade
@@ -0,0 +1,41 @@
"""delete_opinions
Revision ID: i4d5e6f7g8h9
Revises: h3c4d5e6f7g8
Create Date: 2026-01-15 00:00:00.000000
This migration removes opinion facts from memory_units.
Opinions are no longer a separate fact type - they are now represented
through mental model observations with confidence scores.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "i4d5e6f7g8h9"
down_revision: str | Sequence[str] | None = "h3c4d5e6f7g8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Delete opinion memory_units."""
schema = _get_schema_prefix()
# Delete opinion memory_units (cascades to unit_entities links)
# Opinions are now handled through mental model observations
op.execute(f"DELETE FROM {schema}memory_units WHERE fact_type = 'opinion'")
def downgrade() -> None:
"""Cannot restore deleted opinions."""
# Note: Cannot restore deleted opinions - they are lost on downgrade
pass
@@ -0,0 +1,95 @@
"""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")
@@ -0,0 +1,58 @@
"""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'))
""")
@@ -0,0 +1,109 @@
"""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,
)
@@ -0,0 +1,41 @@
"""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)")
@@ -0,0 +1,134 @@
"""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")
@@ -0,0 +1,113 @@
"""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
@@ -0,0 +1,194 @@
"""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
@@ -0,0 +1,50 @@
"""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'))
""")
@@ -0,0 +1,47 @@
"""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
""")
@@ -0,0 +1,53 @@
"""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")
File diff suppressed because it is too large Load Diff
+10 -190
View File
@@ -1,4 +1,4 @@
"""Hindsight MCP Server implementation using FastMCP."""
"""Hindsight MCP Server implementation using FastMCP (HTTP transport)."""
import json
import logging
@@ -8,8 +8,7 @@ from contextvars import ContextVar
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
@@ -52,194 +51,15 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
# Use stateless_http=True for Claude Code compatibility
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
@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.
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
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
)
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 backgrounds.
"""
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, background: 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
background: Optional background context about the bank's owner/purpose
"""
try:
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name/background if provided
if name is not None or background is not None:
await memory.update_bank(
bank_id,
name=name,
background=background,
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}"}}'
register_mcp_tools(mcp, memory, config)
return mcp
+166 -26
View File
@@ -4,9 +4,12 @@ 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
@@ -36,15 +39,29 @@ 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_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_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
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"
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
ENV_EMBEDDINGS_COHERE_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL"
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
# LiteLLM gateway configuration (for embeddings and reranker via LiteLLM proxy)
ENV_LITELLM_API_BASE = "HINDSIGHT_API_LITELLM_API_BASE"
ENV_LITELLM_API_KEY = "HINDSIGHT_API_LITELLM_API_KEY"
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
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"
@@ -59,6 +76,7 @@ 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"
@@ -67,6 +85,7 @@ ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# Observation thresholds
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
@@ -79,6 +98,11 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
# Mental models settings
ENV_ENABLE_MENTAL_MODELS = "HINDSIGHT_API_ENABLE_MENTAL_MODELS"
ENV_CONSOLIDATION_SIMILARITY_THRESHOLD = "HINDSIGHT_API_CONSOLIDATION_SIMILARITY_THRESHOLD"
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
@@ -92,10 +116,16 @@ 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"
# 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"
# 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_BATCH_SIZE = "HINDSIGHT_API_WORKER_BATCH_SIZE"
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
# Reflect agent settings
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
# Default values
DEFAULT_DATABASE_URL = "pg0"
@@ -121,9 +151,15 @@ DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
# LiteLLM defaults
DEFAULT_LITELLM_API_BASE = "http://localhost:4000"
DEFAULT_EMBEDDINGS_LITELLM_MODEL = "text-embedding-3-small"
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"
@@ -131,6 +167,7 @@ DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traver
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
# Observation thresholds
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
@@ -144,6 +181,11 @@ DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "ver
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
# Mental models defaults
DEFAULT_ENABLE_MENTAL_MODELS = False # Mental models disabled by default (experimental)
DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD = 0.75 # Minimum similarity to consider a learning related
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
@@ -153,10 +195,16 @@ DEFAULT_DB_POOL_MAX_SIZE = 100
DEFAULT_DB_COMMAND_TIMEOUT = 60 # seconds
DEFAULT_DB_ACQUIRE_TIMEOUT = 30 # seconds
# 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
# 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_BATCH_SIZE = 10 # Tasks to claim per poll cycle
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -182,6 +230,36 @@ 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()
@@ -220,10 +298,17 @@ class HindsightConfig:
reflect_llm_model: str | None
reflect_llm_base_url: str | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
consolidation_llm_model: str | None
consolidation_llm_base_url: str | None
# Embeddings
embeddings_provider: str
embeddings_local_model: str
embeddings_tei_url: str | None
embeddings_openai_base_url: str | None
embeddings_cohere_base_url: str | None
# Reranker
reranker_provider: str
@@ -232,11 +317,13 @@ class HindsightConfig:
reranker_tei_batch_size: int
reranker_tei_max_concurrent: int
reranker_max_candidates: int
reranker_cohere_base_url: str | None
# Server
host: str
port: int
log_level: str
log_format: str
mcp_enabled: bool
# Recall
@@ -244,6 +331,7 @@ class HindsightConfig:
mpfp_top_k_neighbors: int
recall_max_concurrent: int
recall_connection_budget: int
mental_model_refresh_concurrency: int
# Observation thresholds
observation_min_facts: int
@@ -256,6 +344,11 @@ class HindsightConfig:
retain_extraction_mode: str
retain_observations_async: bool
# Mental models settings
enable_mental_models: bool
consolidation_similarity_threshold: float
consolidation_batch_size: int
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
@@ -269,10 +362,16 @@ class HindsightConfig:
db_command_timeout: int
db_acquire_timeout: int
# Background task processing
task_backend: str
task_backend_memory_batch_size: int
task_backend_memory_batch_interval: float
# Worker configuration (distributed task processing)
worker_enabled: bool
worker_id: str | None
worker_poll_interval_ms: int
worker_max_retries: int
worker_batch_size: int
worker_http_port: int
# Reflect agent settings
reflect_max_iterations: int
@classmethod
def from_env(cls) -> "HindsightConfig":
@@ -296,10 +395,16 @@ class HindsightConfig:
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or 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,
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 None,
consolidation_llm_base_url=os.getenv(ENV_CONSOLIDATION_LLM_BASE_URL) or 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_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),
@@ -309,10 +414,12 @@ class HindsightConfig:
os.getenv(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT))
),
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
# Server
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),
@@ -321,6 +428,9 @@ class HindsightConfig:
recall_connection_budget=int(
os.getenv(ENV_RECALL_CONNECTION_BUDGET, str(DEFAULT_RECALL_CONNECTION_BUDGET))
),
mental_model_refresh_concurrency=int(
os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY))
),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
@@ -345,6 +455,15 @@ class HindsightConfig:
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
).lower()
== "true",
# Mental models settings
enable_mental_models=os.getenv(ENV_ENABLE_MENTAL_MODELS, str(DEFAULT_ENABLE_MENTAL_MODELS)).lower()
== "true",
consolidation_similarity_threshold=float(
os.getenv(ENV_CONSOLIDATION_SIMILARITY_THRESHOLD, str(DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD))
),
consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
),
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
# Database connection pool
@@ -352,14 +471,15 @@ 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))),
# 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))
),
# 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_batch_size=int(os.getenv(ENV_WORKER_BATCH_SIZE, str(DEFAULT_WORKER_BATCH_SIZE))),
worker_http_port=int(os.getenv(ENV_WORKER_HTTP_PORT, str(DEFAULT_WORKER_HTTP_PORT))),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
)
def get_llm_base_url(self) -> str:
@@ -390,12 +510,28 @@ 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."""
logging.basicConfig(
level=self.get_python_log_level(),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
force=True, # Override any existing configuration
)
"""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)
def log_config(self) -> None:
"""Log the current configuration (without sensitive values)."""
@@ -409,6 +545,10 @@ 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}")
@@ -0,0 +1,5 @@
"""Consolidation engine for automatic learning creation from memories."""
from .consolidator import run_consolidation_job
__all__ = ["run_consolidation_job"]
@@ -0,0 +1,810 @@
"""Consolidation engine for automatic mental model creation from memories.
The consolidation engine runs as a background job after retain operations complete.
It processes new memories and either:
- Creates new mental models from novel facts
- Updates existing mental models when new evidence supports/contradicts/refines them
Mental models are stored in memory_units with fact_type='mental_model' and include:
- proof_count: Number of supporting memories
- source_memory_ids: Array of memory UUIDs that contribute to this mental model
- 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_mental_models:
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,
"mental_models_created": 0,
"mental_models_updated": 0,
"mental_models_merged": 0,
"actions_executed": 0,
"skipped": 0,
}
batch_num = 0
while True:
batch_num += 1
batch_start = time.time()
# 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, 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["mental_models_created"] += 1
stats["actions_executed"] += 1
elif action == "updated":
stats["mental_models_updated"] += 1
stats["actions_executed"] += 1
elif action == "merged":
stats["mental_models_merged"] += 1
stats["actions_executed"] += 1
elif action == "multiple":
stats["mental_models_created"] += result.get("created", 0)
stats["mental_models_updated"] += result.get("updated", 0)
stats["mental_models_merged"] += result.get("merged", 0)
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
# Log progress periodically
if stats["memories_processed"] % 10 == 0:
logger.info(
f"[CONSOLIDATION] bank={bank_id} progress: "
f"{stats['memories_processed']}/{total_count} memories processed"
)
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)"
)
# Build summary
perf.log(
f"[3] Results: {stats['memories_processed']} memories -> "
f"{stats['actions_executed']} actions "
f"({stats['mental_models_created']} created, "
f"{stats['mental_models_updated']} updated, "
f"{stats['mental_models_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)}")
perf.flush()
return {"status": "completed", "bank_id": bank_id, **stats}
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 mental models (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 models: returns create action(s) with extracted durable knowledge
- Related models 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 mental models using the full recall system (NO tag filtering)
t0 = time.time()
related_mental_models = await _find_related_mental_models(
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 models)
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
fact_tags=fact_tags,
mental_models=related_mental_models, # 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,
mental_models=related_mental_models,
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,
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
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],
mental_models: list[dict[str, Any]],
source_mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute an update action on an existing mental model.
Updates the mental model text, adds to history, increments proof_count,
and updates mentioned_at if the new source memory has a more recent date.
"""
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 mental model
model = next((m for m in mental_models 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)
# 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 mental model
# Update mentioned_at if source memory has a more recent date
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,
updated_at = now(),
mentioned_at = GREATEST(mentioned_at, COALESCE($7, mentioned_at))
WHERE id = $6
""",
new_text,
embedding_str,
json.dumps(history),
source_ids,
len(source_ids),
uuid.UUID(learning_id),
source_mentioned_at,
)
# Create links from memory to mental model
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 mental model {learning_id} with memory {memory_id}")
return {"action": "updated", "mental_model_id": learning_id}
async def _execute_create_action(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
event_date: datetime | None = None,
occurred_start: datetime | None = None,
mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute a create action for a new mental model.
Creates a new mental model with the specified text and tags.
The text comes directly from the classify LLM - no second LLM call needed.
"""
text = action.get("text")
tags = action.get("tags", [])
if not text:
return {"action": "skipped", "reason": "missing_text"}
# Use text directly from classify - skip the redundant LLM call
result = await _create_mental_model_directly(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_id=memory_id,
mental_model_text=text, # Text already processed by classify LLM
tags=tags,
event_date=event_date,
occurred_start=occurred_start,
mentioned_at=mentioned_at,
perf=perf,
)
logger.debug(f"Created mental model {result.get('mental_model_id')} from memory {memory_id} (tags: {tags})")
return result
async def _create_memory_links(
conn: "Connection",
memory_id: uuid.UUID,
mental_model_id: uuid.UUID,
) -> None:
"""
Create links between a source memory and its mental model.
This:
1. Creates bidirectional semantic links between memory and mental model
2. Copies existing memory_links from the source memory to the mental model
3. Copies entity links from the source memory to the mental model
This enables graph traversal to find related memories via their mental models.
Note: Uses EXISTS checks to handle the case where source memory was deleted
by a concurrent operation between fetching and link creation.
"""
mu_table = fq_table("memory_units")
ml_table = fq_table("memory_links")
ue_table = fq_table("unit_entities")
# 1. Bidirectional link between memory and mental model
# Only insert if both units exist (handles concurrent deletion)
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
SELECT $1, $2, 'semantic', 1.0
WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
ON CONFLICT DO NOTHING
""",
memory_id,
mental_model_id,
)
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
SELECT $1, $2, 'semantic', 1.0
WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
# 2. Copy outgoing memory_links from source memory to mental model
# If source memory links to X, mental model should also link to X
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
SELECT $1, ml.to_unit_id, ml.link_type, ml.entity_id, ml.weight
FROM {ml_table} ml
WHERE ml.from_unit_id = $2 AND ml.to_unit_id != $1
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.to_unit_id)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
# 3. Copy incoming memory_links from source memory to mental model
# If X links to source memory, X should also link to mental model
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
SELECT ml.from_unit_id, $1, ml.link_type, ml.entity_id, ml.weight
FROM {ml_table} ml
WHERE ml.to_unit_id = $2 AND ml.from_unit_id != $1
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.from_unit_id)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
# 4. Copy entity links from source memory to mental model
await conn.execute(
f"""
INSERT INTO {ue_table} (unit_id, entity_id)
SELECT $1, ue.entity_id
FROM {ue_table} ue
WHERE ue.unit_id = $2
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
async def _find_related_mental_models(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
Find mental models related to the given query using the full recall system.
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
potentially related mental models regardless of scope, so the LLM can
decide on tag routing (same scope update vs cross-scope create).
This leverages:
- Semantic search (embedding similarity)
- BM25 text search (keyword matching)
- Entity-based retrieval (shared entities)
- Graph traversal (connected via entity links)
Returns:
List of related mental models with their tags for LLM tag routing
"""
# Use recall to find related mental models
# NO tags parameter - we want ALL mental models regardless of scope
# Use low max_tokens since we only need mental models, not memories
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=5000, # Token budget for mental models
fact_type=["mental_model"], # Only retrieve mental models
request_context=request_context,
_quiet=True, # Suppress logging
# NO tags parameter - intentionally get ALL mental models
)
# If no mental models returned, return empty list
# When fact_type=["mental_model"], results come back in `results` field
if not recall_result.results:
return []
# Trust recall's relevance filtering - fetch full data for each mental model
results = []
for mm in recall_result.results:
# Fetch full mental model data from DB to get history, source_memory_ids, tags
row = await conn.fetchrow(
f"""
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
FROM {fq_table("memory_units")}
WHERE id = $1 AND bank_id = $2 AND fact_type = 'mental_model'
""",
uuid.UUID(mm.id),
bank_id,
)
if row:
history = row["history"]
if isinstance(history, str):
history = json.loads(history)
elif history is None:
history = []
results.append(
{
"id": row["id"],
"text": row["text"],
"proof_count": row["proof_count"] or 1,
"history": history,
"tags": row["tags"] or [], # Include tags for LLM tag routing
"source_memory_ids": row["source_memory_ids"] or [],
"similarity": 1.0, # Retrieved via recall so assumed relevant
}
)
return results
async def _consolidate_with_llm(
memory_engine: "MemoryEngine",
fact_text: str,
fact_tags: list[str],
mental_models: 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 mental models: extracts durable knowledge, returns create action
- Related models exist: compares and returns update/create actions
- Purely ephemeral fact: returns empty array
Returns:
List of actions, each being:
- {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
- {"action": "create", "tags": [...], "text": "...", "reason": "..."}
- [] if fact is purely ephemeral (no durable knowledge)
"""
# Format mental models WITH their tags (or "None" if empty)
if mental_models:
mental_models_text = "\n".join(
f'- ID: {mm["id"]}, Tags: {json.dumps(mm["tags"])}, Text: "{mm["text"]}" (proof_count: {mm["proof_count"]})'
for mm in mental_models
)
else:
mental_models_text = "None (this is a new topic - create if fact contains durable knowledge)"
# 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,
fact_tags=json.dumps(fact_tags),
mental_models_text=mental_models_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):
result = json.loads(result)
# 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_mental_model_directly(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
source_memory_id: uuid.UUID,
mental_model_text: str,
tags: list[str] | None = None,
event_date: datetime | None = None,
occurred_start: datetime | None = None,
mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Create a mental model 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 mental model (convert to string for pgvector)
t0 = time.time()
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [mental_model_text])
embedding_str = str(embeddings[0]) if embeddings else None
if perf:
perf.record_timing("embedding", time.time() - t0)
# Create the mental model as a memory_unit
now = datetime.now(timezone.utc)
mm_event_date = event_date or now
mm_occurred_start = occurred_start or now
mm_mentioned_at = mentioned_at or now
mm_tags = tags or []
t0 = time.time()
mental_model_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, mentioned_at
)
VALUES ($1, $2, $3, 'mental_model', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9)
RETURNING id
""",
mental_model_id,
bank_id,
mental_model_text,
embedding_str,
[source_memory_id],
mm_tags,
mm_event_date,
mm_occurred_start,
mm_mentioned_at,
)
# Create links between memory and mental model (includes entity links, memory_links)
await _create_memory_links(conn, source_memory_id, mental_model_id)
if perf:
perf.record_timing("db_write", time.time() - t0)
logger.debug(f"Created mental model {mental_model_id} from memory {source_memory_id} (tags: {mm_tags})")
return {"action": "created", "mental_model_id": str(row["id"]), "tags": mm_tags}
@@ -0,0 +1,91 @@
"""Prompts for the consolidation engine."""
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (mental models) 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 mental models):
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
## TAG ROUTING RULES:
Tags define visibility scopes. The fact and each mental model have tags (can be empty = global).
| Fact Tags | Model Tags | Action |
|-----------|------------|--------|
| [alice] | [alice] | UPDATE the model (same scope) |
| [alice] | [] | UPDATE the model (global absorbs all scopes) |
| [alice] | [bob] | CREATE new untagged model (cross-scope insight) |
| [] | [alice] | UPDATE the model (untagged facts can update any scope) |
| [] | [] | UPDATE the model (global to global) |
When NO existing model matches the fact's topic: CREATE new model with fact's tags.
## MULTIPLE ACTIONS:
One fact can trigger MULTIPLE actions. For example:
- Update a scoped model [alice] about pizza preferences
- AND update a global model [] about pizza in general
Output an ARRAY of actions (can be empty, one, or many).
## 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 mental models focused on ONE specific topic per person
- Cross-scope insights (alice's fact about bob's topic) become UNTAGGED (global)
- The "text" field MUST contain durable knowledge, not ephemeral state"""
CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowledge.
{mission_section}
NEW FACT: {fact_text}
FACT TAGS: {fact_tags}
EXISTING MENTAL MODELS:
{mental_models_text}
Instructions:
1. First, extract the DURABLE KNOWLEDGE from the fact (not ephemeral state like "user is at X")
2. Then compare with existing mental models:
- If a model covers the same topic: UPDATE it with the new knowledge
- If no model covers the topic: CREATE a new one
- If fact is about different scope: apply tag routing rules
Output JSON array of actions (ALWAYS an array, even for single action):
[
{{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}},
{{"action": "create", "tags": ["tag"], "text": "new durable knowledge", "reason": "..."}}
]
If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
[]
If no models exist and fact contains durable knowledge:
[{{"action": "create", "tags": {fact_tags}, "text": "durable knowledge text", "reason": "new topic"}}]"""
@@ -15,18 +15,24 @@ from concurrent.futures import ThreadPoolExecutor
import httpx
from ..config import (
DEFAULT_LITELLM_API_BASE,
DEFAULT_RERANKER_COHERE_MODEL,
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
ENV_COHERE_API_KEY,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_RERANKER_COHERE_BASE_URL,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_LITELLM_MODEL,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_PROVIDER,
@@ -124,13 +130,28 @@ 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}")
self._model = CrossEncoder(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
# Check for GPU (CUDA) or Apple Silicon (MPS)
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
else:
device = "cpu"
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
@@ -392,6 +413,7 @@ class CohereCrossEncoder(CrossEncoderModel):
self,
api_key: str,
model: str = DEFAULT_RERANKER_COHERE_MODEL,
base_url: str | None = None,
timeout: float = 60.0,
):
"""
@@ -400,10 +422,12 @@ class CohereCrossEncoder(CrossEncoderModel):
Args:
api_key: Cohere API key
model: Cohere rerank model name (default: rerank-english-v3.0)
base_url: Custom base URL for Cohere-compatible API (e.g., Azure-hosted endpoint)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_key = api_key
self.model = model
self.base_url = base_url
self.timeout = timeout
self._client = None
@@ -421,8 +445,14 @@ class CohereCrossEncoder(CrossEncoderModel):
except ImportError:
raise ImportError("cohere is required for CohereCrossEncoder. Install it with: pip install cohere")
logger.info(f"Reranker: initializing Cohere provider with model {self.model}")
self._client = cohere.Client(api_key=self.api_key, timeout=self.timeout)
base_url_msg = f" at {self.base_url}" if self.base_url else ""
logger.info(f"Reranker: initializing Cohere provider with model {self.model}{base_url_msg}")
# Build client kwargs, only including base_url if set (for Azure or custom endpoints)
client_kwargs = {"api_key": self.api_key, "timeout": self.timeout}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = cohere.Client(**client_kwargs)
logger.info("Reranker: Cohere provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
@@ -641,6 +671,116 @@ class FlashRankCrossEncoder(CrossEncoderModel):
return await loop.run_in_executor(FlashRankCrossEncoder._executor, self._predict_sync, pairs)
class LiteLLMCrossEncoder(CrossEncoderModel):
"""
LiteLLM cross-encoder implementation using LiteLLM proxy's /rerank endpoint.
LiteLLM provides a unified interface for multiple reranking providers via
the Cohere-compatible /rerank endpoint.
See: https://docs.litellm.ai/docs/rerank
Supported providers via LiteLLM:
- Cohere (rerank-english-v3.0, etc.) - prefix with cohere/
- Together AI - prefix with together_ai/
- Azure AI - prefix with azure_ai/
- Jina AI - prefix with jina_ai/
- AWS Bedrock - prefix with bedrock/
- Voyage AI - prefix with voyage/
"""
def __init__(
self,
api_base: str = DEFAULT_LITELLM_API_BASE,
api_key: str | None = None,
model: str = DEFAULT_RERANKER_LITELLM_MODEL,
timeout: float = 60.0,
):
"""
Initialize LiteLLM cross-encoder client.
Args:
api_base: Base URL of the LiteLLM proxy (default: http://localhost:4000)
api_key: API key for the LiteLLM proxy (optional, depends on proxy config)
model: Reranking model name (default: cohere/rerank-english-v3.0)
Use provider prefix (e.g., cohere/, together_ai/, voyage/)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_base = api_base.rstrip("/")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._async_client: httpx.AsyncClient | None = None
@property
def provider_name(self) -> str:
return "litellm"
async def initialize(self) -> None:
"""Initialize the async HTTP client."""
if self._async_client is not None:
return
logger.info(f"Reranker: initializing LiteLLM provider at {self.api_base} with model {self.model}")
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self._async_client = httpx.AsyncClient(timeout=self.timeout, headers=headers)
logger.info("Reranker: LiteLLM provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using the LiteLLM proxy's /rerank endpoint.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if self._async_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
# Group pairs by query (LiteLLM rerank expects one query with multiple documents)
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
# LiteLLM /rerank follows Cohere API format
response = await self._async_client.post(
f"{self.api_base}/rerank",
json={
"model": self.model,
"query": query,
"documents": texts,
"top_n": len(texts), # Return all scores
},
)
response.raise_for_status()
result = response.json()
# Map scores back to original positions
# Response format: {"results": [{"index": 0, "relevance_score": 0.9}, ...]}
for item in result.get("results", []):
original_idx = item["index"]
score = item.get("relevance_score", item.get("score", 0.0))
all_scores[indices[original_idx]] = score
return all_scores
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on environment variables.
@@ -671,14 +811,20 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
if not api_key:
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL)
return CohereCrossEncoder(api_key=api_key, model=model)
base_url = os.environ.get(ENV_RERANKER_COHERE_BASE_URL) or None
return CohereCrossEncoder(api_key=api_key, model=model, base_url=base_url)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir)
elif provider == "litellm":
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL)
return LiteLLMCrossEncoder(api_base=api_base, api_key=api_key, model=model)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'rrf'"
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'rrf'"
)
@@ -0,0 +1,5 @@
"""Directives module for hard rules injected into prompts."""
from .models import Directive
__all__ = ["Directive"]
@@ -0,0 +1,37 @@
"""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
+174 -10
View File
@@ -17,16 +17,23 @@ import httpx
from ..config import (
DEFAULT_EMBEDDINGS_COHERE_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_LITELLM_API_BASE,
ENV_COHERE_API_KEY,
ENV_EMBEDDINGS_COHERE_BASE_URL,
ENV_EMBEDDINGS_COHERE_MODEL,
ENV_EMBEDDINGS_LITELLM_MODEL,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_TEI_URL,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_LLM_API_KEY,
)
@@ -121,11 +128,24 @@ class LocalSTEmbeddings(Embeddings):
)
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
# 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
# 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
# Check for GPU (CUDA) or Apple Silicon (MPS)
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
else:
device = "cpu"
self._model = SentenceTransformer(
self.model_name,
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
self._dimension = self._model.get_sentence_embedding_dimension()
@@ -322,6 +342,7 @@ class OpenAIEmbeddings(Embeddings):
self,
api_key: str,
model: str = DEFAULT_EMBEDDINGS_OPENAI_MODEL,
base_url: str | None = None,
batch_size: int = 100,
max_retries: int = 3,
):
@@ -331,11 +352,13 @@ class OpenAIEmbeddings(Embeddings):
Args:
api_key: OpenAI API key
model: OpenAI embedding model name (default: text-embedding-3-small)
base_url: Custom base URL for OpenAI-compatible API (e.g., Azure OpenAI endpoint)
batch_size: Maximum batch size for embedding requests (default: 100)
max_retries: Maximum number of retries for failed requests (default: 3)
"""
self.api_key = api_key
self.model = model
self.base_url = base_url
self.batch_size = batch_size
self.max_retries = max_retries
self._client = None
@@ -361,8 +384,14 @@ class OpenAIEmbeddings(Embeddings):
except ImportError:
raise ImportError("openai is required for OpenAIEmbeddings. Install it with: pip install openai")
logger.info(f"Embeddings: initializing OpenAI provider with model {self.model}")
self._client = OpenAI(api_key=self.api_key, max_retries=self.max_retries)
base_url_msg = f" at {self.base_url}" if self.base_url else ""
logger.info(f"Embeddings: initializing OpenAI provider with model {self.model}{base_url_msg}")
# Build client kwargs, only including base_url if set (for Azure or custom endpoints)
client_kwargs = {"api_key": self.api_key, "max_retries": self.max_retries}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = OpenAI(**client_kwargs)
# Try to get dimension from known models, otherwise do a test embedding
if self.model in self.MODEL_DIMENSIONS:
@@ -435,6 +464,7 @@ class CohereEmbeddings(Embeddings):
self,
api_key: str,
model: str = DEFAULT_EMBEDDINGS_COHERE_MODEL,
base_url: str | None = None,
batch_size: int = 96,
timeout: float = 60.0,
input_type: str = "search_document",
@@ -445,6 +475,7 @@ class CohereEmbeddings(Embeddings):
Args:
api_key: Cohere API key
model: Cohere embedding model name (default: embed-english-v3.0)
base_url: Custom base URL for Cohere-compatible API (e.g., Azure-hosted endpoint)
batch_size: Maximum batch size for embedding requests (default: 96, Cohere's limit)
timeout: Request timeout in seconds (default: 60.0)
input_type: Input type for embeddings (default: search_document).
@@ -452,6 +483,7 @@ class CohereEmbeddings(Embeddings):
"""
self.api_key = api_key
self.model = model
self.base_url = base_url
self.batch_size = batch_size
self.timeout = timeout
self.input_type = input_type
@@ -478,8 +510,14 @@ class CohereEmbeddings(Embeddings):
except ImportError:
raise ImportError("cohere is required for CohereEmbeddings. Install it with: pip install cohere")
logger.info(f"Embeddings: initializing Cohere provider with model {self.model}")
self._client = cohere.Client(api_key=self.api_key, timeout=self.timeout)
base_url_msg = f" at {self.base_url}" if self.base_url else ""
logger.info(f"Embeddings: initializing Cohere provider with model {self.model}{base_url_msg}")
# Build client kwargs, only including base_url if set (for Azure or custom endpoints)
client_kwargs = {"api_key": self.api_key, "timeout": self.timeout}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = cohere.Client(**client_kwargs)
# Try to get dimension from known models, otherwise do a test embedding
if self.model in self.MODEL_DIMENSIONS:
@@ -529,6 +567,123 @@ class CohereEmbeddings(Embeddings):
return all_embeddings
class LiteLLMEmbeddings(Embeddings):
"""
LiteLLM embeddings implementation using LiteLLM proxy's /embeddings endpoint.
LiteLLM provides a unified interface for multiple embedding providers.
The proxy exposes an OpenAI-compatible /embeddings endpoint.
See: https://docs.litellm.ai/docs/embedding/supported_embedding
Supported providers via LiteLLM:
- OpenAI (text-embedding-3-small, text-embedding-ada-002, etc.)
- Cohere (embed-english-v3.0, etc.) - prefix with cohere/
- Vertex AI (textembedding-gecko, etc.) - prefix with vertex_ai/
- HuggingFace, Mistral, Voyage AI, etc.
The embedding dimension is auto-detected from the model at initialization.
"""
def __init__(
self,
api_base: str = DEFAULT_LITELLM_API_BASE,
api_key: str | None = None,
model: str = DEFAULT_EMBEDDINGS_LITELLM_MODEL,
batch_size: int = 100,
timeout: float = 60.0,
):
"""
Initialize LiteLLM embeddings client.
Args:
api_base: Base URL of the LiteLLM proxy (default: http://localhost:4000)
api_key: API key for the LiteLLM proxy (optional, depends on proxy config)
model: Embedding model name (default: text-embedding-3-small)
Use provider prefix for non-OpenAI models (e.g., cohere/embed-english-v3.0)
batch_size: Maximum batch size for embedding requests (default: 100)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_base = api_base.rstrip("/")
self.api_key = api_key
self.model = model
self.batch_size = batch_size
self.timeout = timeout
self._client: httpx.Client | None = None
self._dimension: int | None = None
@property
def provider_name(self) -> str:
return "litellm"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
async def initialize(self) -> None:
"""Initialize the HTTP client and detect embedding dimension."""
if self._client is not None:
return
logger.info(f"Embeddings: initializing LiteLLM provider at {self.api_base} with model {self.model}")
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self._client = httpx.Client(timeout=self.timeout, headers=headers)
# Do a test embedding to detect dimension
try:
response = self._client.post(
f"{self.api_base}/embeddings",
json={"model": self.model, "input": ["test"]},
)
response.raise_for_status()
result = response.json()
if result.get("data") and len(result["data"]) > 0:
self._dimension = len(result["data"][0]["embedding"])
logger.info(f"Embeddings: LiteLLM provider initialized (model: {self.model}, dim: {self._dimension})")
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to LiteLLM proxy at {self.api_base}: {e}")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings using the LiteLLM proxy.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors
"""
if self._client is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
all_embeddings = []
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
response = self._client.post(
f"{self.api_base}/embeddings",
json={"model": self.model, "input": batch},
)
response.raise_for_status()
result = response.json()
# Sort by index to ensure correct order
batch_embeddings = sorted(result["data"], key=lambda x: x["index"])
all_embeddings.extend([e["embedding"] for e in batch_embeddings])
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on environment variables.
@@ -558,12 +713,21 @@ def create_embeddings_from_env() -> Embeddings:
f"when {ENV_EMBEDDINGS_PROVIDER} is 'openai'"
)
model = os.environ.get(ENV_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_OPENAI_MODEL)
return OpenAIEmbeddings(api_key=api_key, model=model)
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "cohere":
api_key = os.environ.get(ENV_COHERE_API_KEY)
if not api_key:
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL)
return CohereEmbeddings(api_key=api_key, model=model)
base_url = os.environ.get(ENV_EMBEDDINGS_COHERE_BASE_URL) or None
return CohereEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "litellm":
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL)
return LiteLLMEmbeddings(api_base=api_base, api_key=api_key, model=model)
else:
raise ValueError(f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere'")
raise ValueError(
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere', 'litellm'"
)
+32 -13
View File
@@ -160,14 +160,14 @@ class MemoryEngineInterface(ABC):
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get bank profile including disposition and background.
Get bank profile including disposition and mission.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Bank profile dict.
Bank profile dict with bank_id, name, disposition, and mission.
"""
...
@@ -190,25 +190,44 @@ class MemoryEngineInterface(ABC):
...
@abstractmethod
async def merge_bank_background(
async def merge_bank_mission(
self,
bank_id: str,
new_info: str,
*,
update_disposition: bool = True,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Merge new background information into bank profile.
Merge new mission information into bank profile.
Args:
bank_id: The memory bank ID.
new_info: New background information to merge.
update_disposition: Whether to infer disposition from background.
new_info: New mission information to merge.
request_context: Request context for authentication.
Returns:
Updated background info.
Updated mission info.
"""
...
@abstractmethod
async def set_bank_mission(
self,
bank_id: str,
mission: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Set the bank's mission (replaces existing).
Args:
bank_id: The memory bank ID.
mission: The mission text.
request_context: Request context for authentication.
Returns:
Dict with bank_id and mission.
"""
...
@@ -518,7 +537,7 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
) -> dict[str, Any]:
"""
List async operations for a bank.
@@ -527,7 +546,7 @@ class MemoryEngineInterface(ABC):
request_context: Request context for authentication.
Returns:
List of operation dicts with id, task_type, status, etc.
Dict with 'total' (int) and 'operations' (list of operation dicts).
"""
...
@@ -561,16 +580,16 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
name: str | None = None,
background: str | None = None,
mission: str | None = None,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Update bank name and/or background.
Update bank name and/or mission.
Args:
bank_id: The memory bank ID.
name: New bank name (optional).
background: New background text (optional, replaces existing).
mission: New mission text (optional, replaces existing).
request_context: Request context for authentication.
Returns:
+505 -43
View File
@@ -209,10 +209,10 @@ class LLMProvider:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
queue_start_time = time.time()
semaphore_start = time.time()
async with _global_llm_semaphore:
semaphore_wait_time = time.time() - semaphore_start
start_time = time.time()
semaphore_wait_time = start_time - queue_start_time
# Handle Mock provider (for testing)
if self.provider == "mock":
@@ -318,43 +318,44 @@ class LLMProvider:
last_exception = None
# Prepare response format ONCE before the retry loop
# (to avoid appending schema to messages on every retry)
if response_format is not None:
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
# This guarantees all required fields are returned
call_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": True,
"schema": schema,
},
}
else:
# Soft enforcement: add schema to prompt and use json_object mode
if schema is not None:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
call_params["messages"][0]["content"] += schema_msg
elif call_params["messages"]:
call_params["messages"][0]["content"] = (
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
# We rely on the schema in the system message instead
call_params["response_format"] = {"type": "json_object"}
for attempt in range(max_retries + 1):
try:
if response_format is not None:
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
# This guarantees all required fields are returned
call_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": True,
"schema": schema,
},
}
else:
# Soft enforcement: add schema to prompt and use json_object mode
if schema is not None:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
call_params["messages"][0]["content"] += schema_msg
elif call_params["messages"]:
call_params["messages"][0]["content"] = (
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
# We rely on the schema in the system message instead
call_params["response_format"] = {"type": "json_object"}
logger.debug(f"Sending request to {self.provider}/{self.model} (timeout={self.timeout})")
response = await self._client.chat.completions.create(**call_params)
logger.debug(f"Received response from {self.provider}/{self.model}")
content = response.choices[0].message.content
@@ -467,13 +468,11 @@ class LLMProvider:
except APIConnectionError as e:
last_exception = e
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(f"APIConnectionError (HTTP {status_code}), attempt {attempt + 1}: {str(e)[:200]}")
if attempt < max_retries:
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(
f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}"
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
@@ -487,6 +486,45 @@ class LLMProvider:
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
# Handle tool_use_failed error - model outputted in tool call format
# Convert to expected JSON format and continue
if e.status_code == 400 and response_format is not None:
try:
error_body = e.body if hasattr(e, "body") else {}
if isinstance(error_body, dict):
error_info: dict[str, Any] = error_body.get("error") or {}
if error_info.get("code") == "tool_use_failed":
failed_gen = error_info.get("failed_generation", "")
if failed_gen:
# Parse the tool call format and convert to actions format
tool_call = json.loads(failed_gen)
tool_name = tool_call.get("name", "")
tool_args = tool_call.get("arguments", {})
# Convert to actions format: {"actions": [{"tool": "name", ...args}]}
converted = {"actions": [{"tool": tool_name, **tool_args}]}
if skip_validation:
result = converted
else:
result = response_format.model_validate(converted)
# Record metrics for this successful recovery
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0,
output_tokens=0,
success=True,
)
if return_usage:
return result, TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
return result
except (json.JSONDecodeError, KeyError, TypeError):
pass # Failed to parse tool_use_failed, continue with normal retry
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
@@ -497,14 +535,438 @@ class LLMProvider:
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during LLM call: {type(e).__name__}: {str(e)}")
except Exception:
raise
if last_exception:
raise last_exception
raise RuntimeError("LLM call failed after all retries with no exception captured")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> "LLMToolCallResult":
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or {"type": "function", "function": {"name": "..."}}
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from .response_models import LLMToolCall, LLMToolCallResult
async with _global_llm_semaphore:
start_time = time.time()
# Handle Mock provider
if self.provider == "mock":
return await self._call_with_tools_mock(messages, tools, scope)
# Handle Anthropic separately (uses different tool format)
if self.provider == "anthropic":
return await self._call_with_tools_anthropic(
messages, tools, max_completion_tokens, max_retries, initial_backoff, max_backoff, start_time, scope
)
# Handle Gemini (convert to Gemini tool format)
if self.provider == "gemini":
return await self._call_with_tools_gemini(
messages, tools, max_retries, initial_backoff, max_backoff, start_time, scope
)
# OpenAI-compatible providers (OpenAI, Groq, Ollama, LMStudio)
call_params: dict[str, Any] = {
"model": self.model,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice,
}
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
call_params["temperature"] = temperature
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.chat.completions.create(**call_params)
message = response.choices[0].message
finish_reason = response.choices[0].finish_reason
# Extract tool calls if present
tool_calls: list[LLMToolCall] = []
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments) if tc.function.arguments else {}
except json.JSONDecodeError:
args = {"_raw": tc.function.arguments}
tool_calls.append(LLMToolCall(id=tc.id, name=tc.function.name, arguments=args))
content = message.content
# Record metrics
duration = time.time() - start_time
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
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_with_tools_mock(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
scope: str,
) -> "LLMToolCallResult":
"""Handle mock tool calls for testing."""
from .response_models import LLMToolCallResult
call_record = {
"provider": self.provider,
"model": self.model,
"messages": messages,
"tools": [t.get("function", {}).get("name") for t in tools],
"scope": scope,
}
self._mock_calls.append(call_record)
if self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
return self._mock_response
# Allow setting just tool calls as a list
if isinstance(self._mock_response, list):
from .response_models import LLMToolCall
return LLMToolCallResult(
tool_calls=[
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
for i, tc in enumerate(self._mock_response)
],
finish_reason="tool_calls",
)
return LLMToolCallResult(content="mock response", finish_reason="stop")
async def _call_with_tools_anthropic(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None,
max_retries: int,
initial_backoff: float,
max_backoff: float,
start_time: float,
scope: str,
) -> "LLMToolCallResult":
"""Handle Anthropic tool calling."""
from anthropic import APIConnectionError, APIStatusError
from .response_models import LLMToolCall, LLMToolCallResult
# Convert OpenAI tool format to Anthropic format
anthropic_tools = []
for tool in tools:
func = tool.get("function", {})
anthropic_tools.append(
{
"name": func.get("name", ""),
"description": func.get("description", ""),
"input_schema": func.get("parameters", {"type": "object", "properties": {}}),
}
)
# Convert messages - handle tool results
system_prompt = None
anthropic_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt = (system_prompt + "\n\n" + content) if system_prompt else content
elif role == "tool":
# Anthropic uses tool_result blocks
anthropic_messages.append(
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": msg.get("tool_call_id", ""), "content": content}
],
}
)
elif role == "assistant" and msg.get("tool_calls"):
# Convert assistant tool calls
tool_use_blocks = []
for tc in msg["tool_calls"]:
tool_use_blocks.append(
{
"type": "tool_use",
"id": tc.get("id", ""),
"name": tc.get("function", {}).get("name", ""),
"input": json.loads(tc.get("function", {}).get("arguments", "{}")),
}
)
anthropic_messages.append({"role": "assistant", "content": tool_use_blocks})
else:
anthropic_messages.append({"role": role, "content": content})
call_params: dict[str, Any] = {
"model": self.model,
"messages": anthropic_messages,
"tools": anthropic_tools,
"max_tokens": max_completion_tokens or 4096,
}
if system_prompt:
call_params["system"] = system_prompt
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._anthropic_client.messages.create(**call_params)
# Extract content and tool calls
content_parts = []
tool_calls: list[LLMToolCall] = []
for block in response.content:
if block.type == "text":
content_parts.append(block.text)
elif block.type == "tool_use":
tool_calls.append(LLMToolCall(id=block.id, name=block.name, arguments=block.input or {}))
content = "".join(content_parts) if content_parts else None
finish_reason = "tool_calls" if tool_calls else "stop"
# 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 _call_with_tools_gemini(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_retries: int,
initial_backoff: float,
max_backoff: float,
start_time: float,
scope: str,
) -> "LLMToolCallResult":
"""Handle Gemini tool calling."""
from .response_models import LLMToolCall, LLMToolCallResult
# Convert tools to Gemini format
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
# Convert messages
system_instruction = None
gemini_contents = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
elif role == "tool":
# Gemini uses function_response
gemini_contents.append(
genai_types.Content(
role="user",
parts=[
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=msg.get("name", ""),
response={"result": content},
)
)
],
)
)
elif role == "assistant":
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
config = genai_types.GenerateContentConfig(
system_instruction=system_instruction,
tools=gemini_tools,
)
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._gemini_client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=config,
)
# Extract content and tool calls
content = None
tool_calls: list[LLMToolCall] = []
if response.candidates and response.candidates[0].content:
for part in response.candidates[0].content.parts:
if hasattr(part, "text") and part.text:
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
)
)
finish_reason = "tool_calls" if tool_calls else "stop"
# Record metrics
metrics = get_metrics_collector()
input_tokens = response.usage_metadata.prompt_token_count if response.usage_metadata else 0
output_tokens = response.usage_metadata.candidates_token_count if response.usage_metadata else 0
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=time.time() - start_time,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except genai_errors.APIError as e:
if e.code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
if last_exception:
raise last_exception
raise RuntimeError("Gemini tool call failed")
async def _call_anthropic(
self,
messages: list[dict[str, str]],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
"""
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).
Other types of consolidated knowledge are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
from .models import MentalModel, MentalModelSubtype
__all__ = ["MentalModel", "MentalModelSubtype"]
@@ -0,0 +1,53 @@
"""
Pydantic models for mental models.
"""
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
"""Subtype of mental model.
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
class MentalModel(BaseModel):
"""
A mental model representing synthesized understanding.
Mental models are the agent's consolidated knowledge. Unlike raw facts,
mental models provide:
- A one-liner description for quick scanning/retrieval
- A full summary for deep understanding
- Links to related mental models
"""
id: str = Field(description="Unique identifier within the bank")
bank_id: str = Field(description="Bank this mental model belongs to")
subtype: MentalModelSubtype = Field(description="How this model was created")
name: str = Field(description="Human-readable name")
description: str = Field(description="One-liner for quick scanning and retrieval matching")
summary: str | None = Field(default=None, description="Full synthesized understanding")
# References
entity_id: str | None = Field(default=None, description="Reference to entities table when type=entity")
source_facts: list[str] = Field(default_factory=list, description="Fact IDs used to generate summary")
links: list[str] = Field(default_factory=list, description="Related mental model IDs")
# Tags for scoped visibility (similar to document tags)
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility filtering")
# Timestamps
last_updated: datetime | None = Field(default=None, description="When summary was last regenerated")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
@@ -0,0 +1,20 @@
"""
Reflect agent module for agentic reflection with tools.
The reflect agent uses an iterative loop with tools to:
1. Lookup mental models (existing knowledge)
2. Recall facts (semantic + temporal search)
3. Learn new insights (create/update mental models)
4. Expand memories (get chunk/document context)
"""
from .agent import ReflectAgentResult, run_reflect_agent
from .models import MentalModelInput, ReflectAction, ReflectActionBatch
__all__ = [
"run_reflect_agent",
"ReflectAgentResult",
"ReflectAction",
"ReflectActionBatch",
"MentalModelInput",
]
@@ -0,0 +1,849 @@
"""
Reflect agent - agentic loop for reflection with native tool calling.
Uses hierarchical retrieval:
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
"""
import asyncio
import json
import logging
import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .prompts import FINAL_SYSTEM_PROMPT, _extract_directive_rules, build_final_prompt, build_system_prompt_for_tools
from .tools_schema import get_reflect_tools
def _build_directives_applied(directives: list[dict[str, Any]] | None) -> list[DirectiveInfo]:
"""Build list of DirectiveInfo from directive mental models."""
if not directives:
return []
result = []
for directive in directives:
directive_id = directive.get("id", "")
directive_name = directive.get("name", "")
observations = directive.get("observations", [])
rules = []
for obs in observations:
# Support both Pydantic Observation objects and dicts
if hasattr(obs, "content"):
rules.append(obs.content)
elif isinstance(obs, dict) and obs.get("content"):
rules.append(obs["content"])
result.append(DirectiveInfo(id=directive_id, name=directive_name, rules=rules))
return result
if TYPE_CHECKING:
from ..llm_wrapper import LLMProvider
from ..response_models import LLMToolCall
logger = logging.getLogger(__name__)
DEFAULT_MAX_ITERATIONS = 10
def _normalize_tool_name(name: str) -> str:
"""Normalize tool name from various LLM output formats.
Some LLMs output tool names in non-standard formats:
- 'functions.done' (OpenAI-style prefix)
- 'call=functions.done' (some models)
- 'call=done' (some models)
Returns the normalized tool name (e.g., 'done', 'recall', etc.)
"""
# Handle 'call=functions.name' or 'call=name' format
if name.startswith("call="):
name = name[len("call=") :]
# Handle 'functions.name' format
if name.startswith("functions."):
name = name[len("functions.") :]
return name
def _is_done_tool(name: str) -> bool:
"""Check if the tool name represents the 'done' tool."""
return _normalize_tool_name(name) == "done"
# Pattern to match done() call as text - handles done({...}) with nested JSON
_DONE_CALL_PATTERN = re.compile(r"done\s*\(\s*\{.*$", re.DOTALL)
def _clean_answer_text(text: str) -> str:
"""Clean up answer text by removing any done() tool call syntax.
Some LLMs output the done() call as text instead of a proper tool call.
This strips out patterns like: done({"answer": "...", ...})
"""
# Remove done() call pattern from the end of the text
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
return cleaned if cleaned else text
async def _generate_structured_output(
answer: str,
response_schema: dict,
llm_config: "LLMProvider",
reflect_id: str,
) -> tuple[dict[str, Any] | None, int, int]:
"""Generate structured output from an answer using the provided JSON schema.
Args:
answer: The text answer to extract structured data from
response_schema: JSON Schema for the expected output structure
llm_config: LLM provider for making the extraction call
reflect_id: Reflect ID for logging
Returns:
Tuple of (structured_output, input_tokens, output_tokens).
structured_output is None if generation fails.
"""
try:
from typing import Any as TypingAny
from pydantic import create_model
def _json_schema_type_to_python(field_schema: dict) -> type:
"""Map JSON schema type to Python type for better LLM guidance."""
json_type = field_schema.get("type", "string")
if json_type == "array":
return list
elif json_type == "object":
return dict
elif json_type == "integer":
return int
elif json_type == "number":
return float
elif json_type == "boolean":
return bool
else:
return str
# Build fields from JSON schema properties
schema_props = response_schema.get("properties", {})
required_fields = set(response_schema.get("required", []))
fields: dict[str, TypingAny] = {}
for field_name, field_schema in schema_props.items():
field_type = _json_schema_type_to_python(field_schema)
default = ... if field_name in required_fields else None
fields[field_name] = (field_type, default)
if not fields:
return None
DynamicModel = create_model("StructuredResponse", **fields)
# Include the full schema in the prompt for better LLM guidance
schema_str = json.dumps(response_schema, indent=2)
# Call LLM with the answer to extract structured data
structured_prompt = f"""Based on this answer, extract the information into the requested structured format.
Answer: {answer}
JSON Schema to follow:
```json
{schema_str}
```
Return ONLY a valid JSON object that matches this exact schema. Pay special attention to field types:
- "type": "array" means the value must be a JSON array/list, NOT a string
- "type": "string" means the value must be a string
- "type": "object" means the value must be a JSON object
Do not include any explanation, only the JSON object."""
structured_result, usage = await llm_config.call(
messages=[
{
"role": "system",
"content": "Extract structured data from the given answer. Return only valid JSON matching the provided schema exactly.",
},
{"role": "user", "content": structured_prompt},
],
response_format=DynamicModel,
scope="reflect_structured",
skip_validation=True, # We'll handle the dict ourselves
return_usage=True,
)
# Convert to dict
if hasattr(structured_result, "model_dump"):
structured_output = structured_result.model_dump()
elif isinstance(structured_result, dict):
structured_output = structured_result
else:
# Try to parse as JSON
structured_output = json.loads(str(structured_result))
logger.info(f"[REFLECT {reflect_id}] Generated structured output with {len(structured_output)} fields")
return structured_output, usage.input_tokens, usage.output_tokens
except Exception as e:
logger.warning(f"[REFLECT {reflect_id}] Failed to generate structured output: {e}")
return None, 0, 0
async def run_reflect_agent(
llm_config: "LLMProvider",
bank_id: str,
query: str,
bank_profile: dict[str, Any],
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
context: str | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
max_tokens: int | None = None,
response_schema: dict | None = None,
directives: list[dict[str, Any]] | None = None,
) -> ReflectAgentResult:
"""
Execute the reflect agent loop using native tool calling.
The agent uses hierarchical retrieval:
1. search_reflections - User-curated summaries (try first)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
Args:
llm_config: LLM provider for agent calls
bank_id: Bank identifier
query: Question to answer
bank_profile: Bank profile with name and mission
search_reflections_fn: Tool callback for searching reflections (query, max_results) -> result
search_mental_models_fn: Tool callback for searching mental models (query, max_results) -> result
recall_fn: Tool callback for recall (query, max_tokens) -> result
expand_fn: Tool callback for expand (memory_ids, depth) -> result
context: Optional additional context
max_iterations: Maximum number of iterations before forcing response
max_tokens: Maximum tokens for the final response
response_schema: Optional JSON Schema for structured output in final response
directives: Optional list of directive mental models to inject as hard rules
Returns:
ReflectAgentResult with final answer and metadata
"""
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
start_time = time.time()
# Build directives_applied for the trace
directives_applied = _build_directives_applied(directives)
# Extract directive rules for tool schema (if any)
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist)
tools = get_reflect_tools(directive_rules=directive_rules)
# Build initial messages (directives are injected into system prompt at START and END)
system_prompt = build_system_prompt_for_tools(bank_profile, context, directives=directives)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
# Tracking
total_tools_called = 0
tool_trace: list[ToolCall] = []
tool_trace_summary: list[dict[str, Any]] = []
llm_trace: list[dict[str, Any]] = []
context_history: list[dict[str, Any]] = [] # For final prompt fallback
# Token usage tracking - accumulate across all LLM calls
total_input_tokens = 0
total_output_tokens = 0
# Track available IDs for validation (prevents hallucinated citations)
available_memory_ids: set[str] = set()
available_reflection_ids: set[str] = set()
available_mental_model_ids: set[str] = set()
def _get_llm_trace() -> list[LLMCall]:
return [
LLMCall(
scope=c["scope"],
duration_ms=c["duration_ms"],
input_tokens=c.get("input_tokens", 0),
output_tokens=c.get("output_tokens", 0),
)
for c in llm_trace
]
def _get_usage() -> TokenUsageSummary:
return TokenUsageSummary(
input_tokens=total_input_tokens,
output_tokens=total_output_tokens,
total_tokens=total_input_tokens + total_output_tokens,
)
def _log_completion(answer: str, iterations: int, forced: bool = False):
elapsed_ms = int((time.time() - start_time) * 1000)
tools_summary = (
", ".join(
f"{t['tool']}({t['input_summary']})={t['duration_ms']}ms/{t.get('output_chars', 0)}c"
for t in tool_trace_summary
)
or "none"
)
llm_summary = ", ".join(f"{c['scope']}={c['duration_ms']}ms" for c in llm_trace) or "none"
total_llm_ms = sum(c["duration_ms"] for c in llm_trace)
total_tools_ms = sum(t["duration_ms"] for t in tool_trace_summary)
answer_preview = answer[:100] + "..." if len(answer) > 100 else answer
mode = "forced" if forced else "done"
logger.info(
f"[REFLECT {reflect_id}] {mode} | "
f"query='{query[:50]}...' | "
f"iterations={iterations} | "
f"llm=[{llm_summary}] ({total_llm_ms}ms) | "
f"tools=[{tools_summary}] ({total_tools_ms}ms) | "
f"answer='{answer_preview}' | "
f"total={elapsed_ms}ms"
)
for iteration in range(max_iterations):
is_last = iteration == max_iterations - 1
if is_last:
# Force text response on last iteration - no tools
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response, usage = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
return_usage=True,
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
llm_trace.append(
{
"scope": "final",
"duration_ms": llm_duration,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
# Call LLM with tools
llm_start = time.time()
try:
result = await llm_config.call_with_tools(
messages=messages,
tools=tools,
scope="reflect_agent",
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += result.input_tokens
total_output_tokens += result.output_tokens
llm_trace.append(
{
"scope": f"agent_{iteration + 1}",
"duration_ms": llm_duration,
"input_tokens": result.input_tokens,
"output_tokens": result.output_tokens,
}
)
except Exception as e:
err_duration = int((time.time() - llm_start) * 1000)
logger.warning(f"[REFLECT {reflect_id}] LLM error on iteration {iteration + 1}: {e} ({err_duration}ms)")
llm_trace.append({"scope": f"agent_{iteration + 1}_err", "duration_ms": err_duration})
# Guardrail: If no evidence gathered yet, retry
has_gathered_evidence = (
bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids)
)
if not has_gathered_evidence and iteration < max_iterations - 1:
continue
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response, usage = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
return_usage=True,
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
llm_trace.append(
{
"scope": "final",
"duration_ms": llm_duration,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
# No tool calls - LLM wants to respond with text
if not result.tool_calls:
if result.content:
answer = _clean_answer_text(result.content.strip())
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
# Empty response, force final
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response, usage = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
return_usage=True,
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
llm_trace.append(
{
"scope": "final",
"duration_ms": llm_duration,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
# Check for done tool call (handle various LLM output formats)
done_call = next((tc for tc in result.tool_calls if _is_done_tool(tc.name)), None)
if done_call:
# Guardrail: Require evidence before done
has_gathered_evidence = (
bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids)
)
if not has_gathered_evidence and iteration < max_iterations - 1:
# Add assistant message and fake tool result asking for evidence
messages.append(
{
"role": "assistant",
"tool_calls": [_tool_call_to_dict(done_call)],
}
)
messages.append(
{
"role": "tool",
"tool_call_id": done_call.id,
"name": done_call.name, # Required by Gemini
"content": json.dumps(
{
"error": "You must search for information first. Use search_reflections(), search_mental_models(), or recall() before providing your final answer."
}
),
}
)
continue
# Process done tool
return await _process_done_tool(
done_call,
available_memory_ids,
available_reflection_ids,
available_mental_model_ids,
iteration + 1,
total_tools_called,
tool_trace,
_get_llm_trace(),
_get_usage(),
_log_completion,
reflect_id,
directives_applied=directives_applied,
llm_config=llm_config,
response_schema=response_schema,
)
# Execute other tools in parallel (exclude done tool in all its format variants)
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
if other_tools:
# Add assistant message with tool calls
messages.append(
{
"role": "assistant",
"tool_calls": [_tool_call_to_dict(tc) for tc in other_tools],
}
)
# Execute tools in parallel
tool_tasks = [
_execute_tool_with_timing(
tc,
search_reflections_fn,
search_mental_models_fn,
recall_fn,
expand_fn,
)
for tc in other_tools
]
tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True)
total_tools_called += len(other_tools)
# Process results and add to messages
for tc, result_data in zip(other_tools, tool_results):
if isinstance(result_data, Exception):
# Tool execution failed - send error back to LLM so it can try again
logger.warning(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
output = {"error": f"Tool execution failed: {result_data}"}
duration_ms = 0
else:
output, duration_ms = result_data
# Normalize tool name for consistent tracking
normalized_tool_name = _normalize_tool_name(tc.name)
# Check if tool returned an error response - log but continue (LLM will see the error)
if isinstance(output, dict) and "error" in output:
logger.warning(
f"[REFLECT {reflect_id}] Tool {normalized_tool_name} returned error: {output['error']}"
)
# Track available IDs from tool results (only for successful responses)
if (
normalized_tool_name == "search_reflections"
and isinstance(output, dict)
and "reflections" in output
):
for reflection in output["reflections"]:
if "id" in reflection:
available_reflection_ids.add(reflection["id"])
if (
normalized_tool_name == "search_mental_models"
and isinstance(output, dict)
and "mental_models" in output
):
for mm in output["mental_models"]:
if "id" in mm:
available_mental_model_ids.add(mm["id"])
if normalized_tool_name == "recall" and isinstance(output, dict) and "memories" in output:
for memory in output["memories"]:
if "id" in memory:
available_memory_ids.add(memory["id"])
# Add tool result message
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name, # Required by Gemini
"content": json.dumps(output, default=str),
}
)
# Track for logging and context history
input_dict = {"tool": tc.name, **tc.arguments}
input_summary = _summarize_input(tc.name, tc.arguments)
tool_trace.append(
ToolCall(
tool=tc.name, input=input_dict, output=output, duration_ms=duration_ms, iteration=iteration + 1
)
)
try:
output_chars = len(json.dumps(output))
except (TypeError, ValueError):
output_chars = len(str(output))
tool_trace_summary.append(
{
"tool": tc.name,
"input_summary": input_summary,
"duration_ms": duration_ms,
"output_chars": output_chars,
}
)
# Keep context history for fallback final prompt
context_history.append({"tool": tc.name, "input": input_dict, "output": output})
# Should not reach here
answer = "I was unable to formulate a complete answer within the iteration limit."
_log_completion(answer, max_iterations, forced=True)
return ReflectAgentResult(
text=answer,
iterations=max_iterations,
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
"""Convert LLMToolCall to OpenAI message format."""
return {
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments),
},
}
async def _process_done_tool(
done_call: "LLMToolCall",
available_memory_ids: set[str],
available_reflection_ids: set[str],
available_mental_model_ids: set[str],
iterations: int,
total_tools_called: int,
tool_trace: list[ToolCall],
llm_trace: list[LLMCall],
usage: TokenUsageSummary,
log_completion: Callable,
reflect_id: str,
directives_applied: list[DirectiveInfo],
llm_config: "LLMProvider | None" = None,
response_schema: dict | None = None,
) -> ReflectAgentResult:
"""Process the done tool call and return the result."""
args = done_call.arguments
answer = args.get("answer", "").strip()
if not answer:
answer = "No answer provided."
# Validate IDs (only include IDs that were actually retrieved)
used_memory_ids = [mid for mid in args.get("memory_ids", []) if mid in available_memory_ids]
used_reflection_ids = [rid for rid in args.get("reflection_ids", []) if rid in available_reflection_ids]
used_mental_model_ids = [mid for mid in args.get("mental_model_ids", []) if mid in available_mental_model_ids]
# Generate structured output if schema provided
structured_output = None
final_usage = usage
if response_schema and llm_config and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
# Add structured output tokens to usage
final_usage = TokenUsageSummary(
input_tokens=usage.input_tokens + struct_in,
output_tokens=usage.output_tokens + struct_out,
total_tokens=usage.total_tokens + struct_in + struct_out,
)
log_completion(answer, iterations)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iterations,
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=llm_trace,
usage=final_usage,
used_memory_ids=used_memory_ids,
used_reflection_ids=used_reflection_ids,
used_mental_model_ids=used_mental_model_ids,
directives_applied=directives_applied,
)
async def _execute_tool_with_timing(
tc: "LLMToolCall",
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
start = time.time()
result = await _execute_tool(
tc.name,
tc.arguments,
search_reflections_fn,
search_mental_models_fn,
recall_fn,
expand_fn,
)
duration_ms = int((time.time() - start) * 1000)
return result, duration_ms
async def _execute_tool(
tool_name: str,
args: dict[str, Any],
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
"""Execute a single tool by name."""
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
if tool_name == "search_reflections":
query = args.get("query")
if not query:
return {"error": "search_reflections requires a query parameter"}
max_results = args.get("max_results") or 5
return await search_reflections_fn(query, max_results)
elif tool_name == "search_mental_models":
query = args.get("query")
if not query:
return {"error": "search_mental_models requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
return await search_mental_models_fn(query, max_tokens)
elif tool_name == "recall":
query = args.get("query")
if not query:
return {"error": "recall requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000
return await recall_fn(query, max_tokens)
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
if not memory_ids:
return {"error": "expand requires memory_ids"}
depth = args.get("depth", "chunk")
return await expand_fn(memory_ids, depth)
else:
return {"error": f"Unknown tool: {tool_name}"}
def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
"""Create a summary of tool input for logging, showing all params."""
if tool_name == "search_reflections":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_results = args.get("max_results") or 5
return f"(query={query_preview}, max_results={max_results})"
elif tool_name == "search_mental_models":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_tokens = max(args.get("max_tokens") or 5000, 1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "recall":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
# Show actual value used (default 2048, min 1000)
max_tokens = max(args.get("max_tokens") or 2048, 1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
depth = args.get("depth", "chunk")
return f"(memory_ids=[{len(memory_ids)} ids], depth={depth})"
elif tool_name == "done":
answer = args.get("answer", "")
answer_preview = f"'{answer[:30]}...'" if len(answer) > 30 else f"'{answer}'"
memory_ids = args.get("memory_ids", [])
reflection_ids = args.get("reflection_ids", [])
mental_model_ids = args.get("mental_model_ids", [])
return (
f"(answer={answer_preview}, mem={len(memory_ids)}, ref={len(reflection_ids)}, mm={len(mental_model_ids)})"
)
return str(args)
@@ -0,0 +1,131 @@
"""
Pydantic models for the reflect agent.
"""
from typing import Any, Literal
from pydantic import BaseModel, Field
class MentalModelObservation(BaseModel):
"""An observation within a mental model with its supporting memories."""
title: str = Field(description="Observation header (can be empty for intro)")
text: str = Field(description="Observation content - no headers, use lists/tables/bold")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
class MentalModelInput(BaseModel):
"""Input for the learn tool to create a mental model placeholder.
The agent only specifies name and description - the actual content/observations
are generated during refresh, similar to pinned models.
"""
name: str = Field(description="Human-readable name for the mental model")
description: str = Field(description="What to track - used as prompt for content generation during refresh")
entity_id: str | None = Field(default=None, description="Optional link to existing entity ID")
class AnswerSection(BaseModel):
"""A section of the answer with its supporting evidence (DEPRECATED)."""
title: str = Field(description="Section header/title")
text: str = Field(description="Section content")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
model_ids: list[str] = Field(default_factory=list, description="Mental model IDs supporting this section")
class ReflectAction(BaseModel):
"""Single action the reflect agent can take."""
tool: Literal["list_mental_models", "get_mental_model", "recall", "learn", "expand", "done"] = Field(
description="Tool to invoke: list_mental_models, get_mental_model, recall, learn, expand, or done"
)
# Tool-specific parameters
model_id: str | None = Field(default=None, description="Mental model ID for get_mental_model")
query: str | None = Field(default=None, description="Search query for recall")
max_tokens: int | None = Field(default=None, description="Max tokens for recall results (default 2048)")
mental_model: MentalModelInput | None = Field(default=None, description="Mental model to create/update for learn")
memory_ids: list[str] | None = Field(default=None, description="Memory unit IDs for expand (batched)")
depth: Literal["chunk", "document"] | None = Field(default=None, description="Expansion depth for expand")
sections: list[AnswerSection] | None = Field(default=None, description="DEPRECATED: Use answer field instead")
observations: list[MentalModelObservation] | None = Field(
default=None, description="Observations for done action (when output_mode=observations)"
)
# Plain text answer fields (for output_mode=answer)
answer: str | None = Field(default=None, description="Plain text answer for done action (no markdown)")
answer_memory_ids: list[str] | None = Field(
default=None, description="Memory IDs supporting the answer", alias="memory_ids"
)
answer_model_ids: list[str] | None = Field(
default=None, description="Mental model IDs supporting the answer", alias="model_ids"
)
reasoning: str | None = Field(default=None, description="Brief reasoning for this action")
class ReflectActionBatch(BaseModel):
"""Batch of actions for parallel execution."""
actions: list[ReflectAction] = Field(description="List of actions to execute in parallel")
class ToolCall(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
iteration: int = Field(default=0, description="Iteration number (1-based) when this tool was called")
class LLMCall(BaseModel):
"""A single LLM call made during reflect."""
scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
duration_ms: int = Field(description="Execution time in milliseconds")
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."""
id: str = Field(description="Directive mental model ID")
name: str = Field(description="Directive name")
rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied")
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)")
class ReflectAgentResult(BaseModel):
"""Result from the reflect agent."""
text: str = Field(description="Final answer text")
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")
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_reflection_ids: list[str] = Field(
default_factory=list, description="Validated reflection IDs actually used in answer"
)
used_mental_model_ids: list[str] = Field(
default_factory=list, description="Validated mental model IDs actually used in answer"
)
directives_applied: list[DirectiveInfo] = Field(
default_factory=list, description="Directive mental models that affected this reflection"
)
@@ -0,0 +1,186 @@
"""
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
@@ -0,0 +1,440 @@
"""
System prompts for the reflect agent.
The reflect agent uses hierarchical retrieval:
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - 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_reflections: bool = False,
) -> str:
"""
Build the system prompt for tool-calling reflect agent.
The agent uses hierarchical retrieval:
1. search_reflections - User-curated summaries (try first, if available)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
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_reflections: Whether the bank has any reflections (skip if not)
"""
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts = []
# Inject directives at the VERY START for maximum prominence
if directives:
parts.append(build_directives_section(directives))
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_reflections:
parts.extend(
[
"You have access to THREE levels of knowledge. Use them in this order:",
"",
"### 1. REFLECTIONS (search_reflections) - Try First",
"- User-curated summaries about specific topics",
"- HIGHEST quality - manually created and maintained",
"- If a relevant reflection exists and is FRESH, it may fully answer the question",
"- Check `is_stale` field - if stale, also verify with lower levels",
"",
"### 2. MENTAL MODELS (search_mental_models) - Second Priority",
"- Auto-consolidated knowledge from memories",
"- Check `is_stale` field - if stale, ALSO use recall() to verify",
"- Good for understanding patterns and summaries",
"",
"### 3. RAW FACTS (recall) - Ground Truth",
"- Individual memories (world facts and experiences)",
"- Use when: no reflections/models exist, they're stale, or you need specific details",
"- This is the source of truth that other levels are built from",
"",
]
)
else:
parts.extend(
[
"You have access to TWO levels of knowledge. Use them in this order:",
"",
"### 1. MENTAL MODELS (search_mental_models) - 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 mental models exist, they're stale, or you need specific details",
"- This is the source of truth that mental models are built from",
"",
]
)
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.",
"",
"## Workflow",
]
)
if has_reflections:
parts.extend(
[
"1. First, try search_reflections() - check if a curated summary exists",
"2. If no reflection or it's stale, try search_mental_models() for consolidated knowledge",
"3. If mental models 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_mental_models() - check for consolidated knowledge",
"2. If mental models 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/reflection_ids/mental_model_ids arrays, not in the answer",
]
)
parts.append("")
parts.append(f"## Memory Bank: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
if context:
parts.append(f"\n## Additional Context\n{context}")
# Add directive reminder at the END for recency effect
if directives:
parts.append(build_directives_reminder(directives))
return "\n".join(parts)
def build_agent_prompt(
query: str,
context_history: list[dict],
bank_profile: dict,
additional_context: str | None = None,
) -> str:
"""Build the user prompt for the reflect agent."""
parts = []
# Bank identity
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts.append(f"## Memory Bank Context\nName: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits if present
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
# Additional context from caller
if additional_context:
parts.append(f"\n## Additional Context\n{additional_context}")
# Tool call history
if context_history:
parts.append("\n## Tool Results (synthesize and reason from this data)")
for i, entry in enumerate(context_history, 1):
tool = entry["tool"]
output = entry["output"]
# Format as proper JSON for LLM readability
try:
output_str = json.dumps(output, indent=2, default=str)
except (TypeError, ValueError):
output_str = str(output)
parts.append(f"\n### Call {i}: {tool}\n```json\n{output_str}\n```")
# The question
parts.append(f"\n## Question\n{query}")
# Instructions
if context_history:
parts.append(
"\n## Instructions\n"
"Based on the tool results above, either call more tools or provide your final answer. "
"Synthesize and reason from the data - make reasonable inferences when helpful. "
"If you have related information, use it to give the best possible answer."
)
else:
parts.append(
"\n## Instructions\n"
"Start by searching for relevant information using the hierarchical retrieval strategy:\n"
"1. Try search_reflections() first for curated summaries\n"
"2. Try search_mental_models() for consolidated knowledge\n"
"3. Use recall() for specific details or to verify stale data"
)
return "\n".join(parts)
def build_final_prompt(
query: str,
context_history: list[dict],
bank_profile: dict,
additional_context: str | None = None,
) -> str:
"""Build the final prompt when forcing a text response (no tools)."""
parts = []
# Bank identity
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts.append(f"## Memory Bank Context\nName: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits if present
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
# Additional context from caller
if additional_context:
parts.append(f"\n## Additional Context\n{additional_context}")
# Tool call history
if context_history:
parts.append("\n## Retrieved Data (synthesize and reason from this data)")
for entry in context_history:
tool = entry["tool"]
output = entry["output"]
# Format as proper JSON for LLM readability
try:
output_str = json.dumps(output, indent=2, default=str)
except (TypeError, ValueError):
output_str = str(output)
parts.append(f"\n### From {tool}:\n```json\n{output_str}\n```")
else:
parts.append("\n## Retrieved Data\nNo data was retrieved.")
# The question
parts.append(f"\n## Question\n{query}")
# Final instructions
parts.append(
"\n## Instructions\n"
"Provide a thoughtful answer by synthesizing and reasoning from the retrieved data above. "
"You can make reasonable inferences from the memories, but don't completely fabricate information."
"If the exact answer isn't stated, use what IS stated to give the best possible answer. "
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question."
)
return "\n".join(parts)
FINAL_SYSTEM_PROMPT = """You are a thoughtful assistant that synthesizes answers from retrieved memories.
Your approach:
- Reason over the retrieved memories to answer the question
- Make reasonable inferences when the exact answer isn't explicitly stated
- Connect related memories to form a complete picture
- Be helpful - if you have related information, use it to give the best possible answer
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
Do NOT fabricate information that has no basis in the retrieved data."""
@@ -0,0 +1,438 @@
"""
Tool implementations for the reflect agent.
Implements hierarchical retrieval:
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
"""
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from asyncpg import Connection
from ...api.http import RequestContext
from ..memory_engine import MemoryEngine
logger = logging.getLogger(__name__)
# Mental model is considered stale if not updated in this many days
STALE_THRESHOLD_DAYS = 7
async def tool_search_reflections(
conn: "Connection",
bank_id: str,
query: str,
query_embedding: list[float],
max_results: int = 5,
tags: list[str] | None = None,
tags_match: str = "any",
exclude_ids: list[str] | None = None,
) -> dict[str, Any]:
"""
Search user-curated reflections by semantic similarity.
Reflections are high-quality, manually created summaries about specific topics.
They should be searched FIRST as they represent the most reliable synthesized knowledge.
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 reflections to return
tags: Optional tags to filter reflections
tags_match: How to match tags - "any" (OR), "all" (AND)
exclude_ids: Optional list of reflection IDs to exclude (e.g., when refreshing a reflection)
Returns:
Dict with matching reflections including content and freshness info
"""
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}::uuid[])"
params.append(exclude_ids)
next_param += 1
# Search reflections by embedding similarity
rows = await conn.fetch(
f"""
SELECT
id, name, content, reflect_response,
tags, created_at, last_refreshed_at,
1 - (embedding <=> $2::vector) as relevance
FROM {fq_table("reflections")}
WHERE bank_id = $1 AND embedding IS NOT NULL {filters}
ORDER BY embedding <=> $2::vector
LIMIT $3
""",
*params,
)
now = datetime.now(timezone.utc)
reflections = []
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)
reflections.append(
{
"id": str(row["id"]),
"name": row["name"],
"content": row["content"],
"reflect_response": row["reflect_response"],
"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,
}
)
return {
"query": query,
"count": len(reflections),
"reflections": reflections,
}
async def tool_search_mental_models(
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 mental models using recall with include_mental_models.
Mental models 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 models
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 mental models including freshness info
"""
from ..memory_engine import fq_table
# Use recall to search mental models (they come back in results field when fact_type=["mental_model"])
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["mental_model"], # Only retrieve mental models
max_tokens=max_tokens, # Token budget controls how many mental models are returned
enable_trace=False,
request_context=request_context,
tags=tags,
tags_match=tags_match,
_connection_budget=1,
_quiet=True,
)
mental_models = []
# When fact_type=["mental_model"], 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:
mm_ids = [m.id for m in result.results]
# Fetch proof_count and source_memory_ids for these mental models
pool = await memory_engine._get_pool()
async with pool.acquire() as conn:
mm_rows = await conn.fetch(
f"""
SELECT id, proof_count, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
mm_ids,
)
mm_data = {str(row["id"]): row for row in mm_rows}
for m in result.results:
# Get additional data from DB lookup
extra = mm_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"
mental_models.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"
else:
freshness = "stale"
return {
"query": query,
"count": len(mental_models),
"mental_models": mental_models,
"freshness": freshness,
}
async def tool_recall(
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
max_tokens: int = 2048,
max_results: int = 50,
tags: list[str] | None = None,
tags_match: str = "any",
connection_budget: int = 1,
) -> dict[str, Any]:
"""
Search memories using TEMPR retrieval.
This is the ground truth - raw facts and experiences.
Use when reflections/mental models don't exist, are stale, or need verification.
Args:
memory_engine: Memory engine instance
bank_id: Bank identifier
query: Search query
request_context: Request context for authentication
max_tokens: Maximum tokens for results (default 2048)
max_results: Maximum number of results
tags: Filter by tags (includes untagged memories)
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
connection_budget: Max DB connections for this recall (default 1 for internal ops)
Returns:
Dict with list of matching memories
"""
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["experience", "world"], # Exclude opinions and mental_models
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 = []
for m in result.results[:max_results]:
memories.append(
{
"id": str(m.id),
"text": m.text,
"type": m.fact_type,
"entities": m.entities or [],
"occurred": m.occurred_start, # Already ISO format string
}
)
return {
"query": query,
"count": len(memories),
"memories": memories,
}
async def tool_expand(
conn: "Connection",
bank_id: str,
memory_ids: list[str],
depth: str,
) -> dict[str, Any]:
"""
Expand multiple memories to get chunk or document context.
Args:
conn: Database connection
bank_id: Bank identifier
memory_ids: List of memory unit IDs
depth: "chunk" or "document"
Returns:
Dict with results array, each containing memory, chunk, and optionally document data
"""
from ..memory_engine import fq_table
if not memory_ids:
return {"error": "memory_ids is required and must not be empty"}
# Validate and convert UUIDs
valid_uuids: list[uuid.UUID] = []
errors: dict[str, str] = {}
for mid in memory_ids:
try:
valid_uuids.append(uuid.UUID(mid))
except ValueError:
errors[mid] = f"Invalid memory_id format: {mid}"
if not valid_uuids:
return {"error": "No valid memory IDs provided", "details": errors}
# Batch fetch all memory units
memories = await conn.fetch(
f"""
SELECT id, text, chunk_id, document_id, fact_type, context
FROM {fq_table("memory_units")}
WHERE id = ANY($1) AND bank_id = $2
""",
valid_uuids,
bank_id,
)
memory_map = {row["id"]: row for row in memories}
# Collect chunk_ids and document_ids for batch fetching
chunk_ids = [m["chunk_id"] for m in memories if m["chunk_id"]]
doc_ids_from_chunks: set[str] = set()
doc_ids_direct: set[str] = set()
# Batch fetch all chunks
chunk_map: dict[str, Any] = {}
if chunk_ids:
chunks = await conn.fetch(
f"""
SELECT chunk_id, chunk_text, chunk_index, document_id
FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1)
""",
chunk_ids,
)
chunk_map = {row["chunk_id"]: row for row in chunks}
if depth == "document":
doc_ids_from_chunks = {c["document_id"] for c in chunks if c["document_id"]}
# Collect direct document IDs (memories without chunks)
if depth == "document":
for m in memories:
if not m["chunk_id"] and m["document_id"]:
doc_ids_direct.add(m["document_id"])
# Batch fetch all documents
doc_map: dict[str, Any] = {}
all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct)
if all_doc_ids:
docs = await conn.fetch(
f"""
SELECT id, original_text, metadata, retain_params
FROM {fq_table("documents")}
WHERE id = ANY($1) AND bank_id = $2
""",
all_doc_ids,
bank_id,
)
doc_map = {row["id"]: row for row in docs}
# Build results
results: list[dict[str, Any]] = []
for mid, mem_uuid in zip(memory_ids, valid_uuids):
if mid in errors:
results.append({"memory_id": mid, "error": errors[mid]})
continue
memory = memory_map.get(mem_uuid)
if not memory:
results.append({"memory_id": mid, "error": f"Memory not found: {mid}"})
continue
item: dict[str, Any] = {
"memory_id": mid,
"memory": {
"id": str(memory["id"]),
"text": memory["text"],
"type": memory["fact_type"],
"context": memory["context"],
},
}
# Add chunk if available
if memory["chunk_id"] and memory["chunk_id"] in chunk_map:
chunk = chunk_map[memory["chunk_id"]]
item["chunk"] = {
"id": chunk["chunk_id"],
"text": chunk["chunk_text"],
"index": chunk["chunk_index"],
"document_id": chunk["document_id"],
}
# Add document if depth=document
if depth == "document" and chunk["document_id"] in doc_map:
doc = doc_map[chunk["document_id"]]
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"retain_params": doc["retain_params"],
}
elif memory["document_id"] and depth == "document" and memory["document_id"] in doc_map:
# No chunk, but has document_id
doc = doc_map[memory["document_id"]]
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"retain_params": doc["retain_params"],
}
results.append(item)
return {"results": results, "count": len(results)}
@@ -0,0 +1,234 @@
"""
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_reflections - User-curated summaries (highest quality, if applicable)
2. search_mental_models - Consolidated knowledge with freshness awareness
3. recall - Raw facts (world/experience) as ground truth fallback
"""
# Tool definitions in OpenAI format
TOOL_SEARCH_REFLECTIONS = {
"type": "function",
"function": {
"name": "search_reflections",
"description": (
"Search user-curated reflections (summaries). These are high-quality, manually created "
"summaries about specific topics. Use FIRST when the question might be covered by an "
"existing reflection. Returns reflections with their content and last refresh time."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to find relevant reflections",
},
"max_results": {
"type": "integer",
"description": "Maximum number of reflections to return (default 5)",
},
},
"required": ["query"],
},
},
}
TOOL_SEARCH_MENTAL_MODELS = {
"type": "function",
"function": {
"name": "search_mental_models",
"description": (
"Search consolidated mental models (auto-generated knowledge). These are automatically "
"synthesized from memories. Returns models with freshness info (updated_at, is_stale). "
"If a model is STALE, you should ALSO use recall() to verify with current facts."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to find relevant mental models",
},
"max_tokens": {
"type": "integer",
"description": "Maximum tokens for results (default 5000). Use higher values for broader searches.",
},
},
"required": ["query"],
},
},
}
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."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query string",
},
"max_tokens": {
"type": "integer",
"description": "Optional limit on result size (default 2048). Use higher values for broader searches.",
},
},
"required": ["query"],
},
},
}
TOOL_EXPAND = {
"type": "function",
"function": {
"name": "expand",
"description": "Get more context for one or more memories. Memory hierarchy: memory -> chunk -> document.",
"parameters": {
"type": "object",
"properties": {
"memory_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of memory IDs from recall results (batch multiple for efficiency)",
},
"depth": {
"type": "string",
"enum": ["chunk", "document"],
"description": "chunk: surrounding text chunk, document: full source document",
},
},
"required": ["memory_ids", "depth"],
},
},
}
TOOL_DONE_ANSWER = {
"type": "function",
"function": {
"name": "done",
"description": "Signal completion with your final answer. Use this when you have gathered enough information to answer the question.",
"parameters": {
"type": "object",
"properties": {
"answer": {
"type": "string",
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
"reflection_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of reflection IDs that support your answer",
},
"mental_model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model 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)",
},
"reflection_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of reflection IDs that support your answer",
},
"mental_model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model 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]...'",
},
},
"required": ["answer", "directive_compliance"],
},
},
}
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
"""
Get the list of tools for the reflect agent.
The tools support a hierarchical retrieval strategy:
1. search_reflections - User-curated summaries (try first)
2. search_mental_models - 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.
Returns:
List of tool definitions in OpenAI format
"""
tools = [
TOOL_SEARCH_REFLECTIONS,
TOOL_SEARCH_MENTAL_MODELS,
TOOL_RECALL,
TOOL_EXPAND,
]
# Use directive-aware done tool if directives are present
if directive_rules:
tools.append(_build_done_tool_with_directives(directive_rules))
else:
tools.append(TOOL_DONE_ANSWER)
return tools
@@ -10,8 +10,62 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'observation' which is internal)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"])
# Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "mental_model"])
class LLMToolCall(BaseModel):
"""A tool call requested by the LLM."""
id: str = Field(description="Unique identifier for this tool call")
name: str = Field(description="Name of the tool to call")
arguments: dict[str, Any] = Field(description="Arguments to pass to the tool")
class LLMToolCallResult(BaseModel):
"""Result from an LLM call that may include tool calls."""
content: str | None = Field(default=None, description="Text content if any")
tool_calls: list[LLMToolCall] = Field(default_factory=list, description="Tool calls requested by the LLM")
finish_reason: str | None = Field(default=None, description="Reason the LLM stopped: 'stop', 'tool_calls', etc.")
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")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
iteration: int = Field(default=0, description="Iteration number (1-based) when this tool was called")
class LLMCallTrace(BaseModel):
"""A single LLM call made during reflect."""
scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
duration_ms: int = Field(description="Execution time in milliseconds")
class MentalModelRef(BaseModel):
"""Reference to a mental model accessed during reflect."""
id: str = Field(description="Mental model ID")
name: str = Field(description="Mental model name")
type: str = Field(description="Mental model type: entity, concept, event")
subtype: str = Field(description="Mental model subtype: structural, emergent, learned")
description: str = Field(description="Brief description")
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
class 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")
rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied")
class TokenUsage(BaseModel):
@@ -85,6 +139,7 @@ class MemoryFact(BaseModel):
"metadata": {"source": "slack"},
"chunk_id": "bank123_session_abc123_0",
"activation": 0.95,
"tags": ["user_a", "session_123"],
}
}
)
@@ -102,6 +157,7 @@ class MemoryFact(BaseModel):
chunk_id: str | None = Field(
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
)
tags: list[str] | None = Field(None, description="Visibility scope tags associated with this fact")
class ChunkInfo(BaseModel):
@@ -112,6 +168,28 @@ class ChunkInfo(BaseModel):
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
class MentalModelResult(BaseModel):
"""A mental model result from recall."""
id: str = Field(description="Unique mental model ID")
text: str = Field(description="The mental model text")
proof_count: int = Field(description="Number of facts supporting this mental model")
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 mental model"
)
class ReflectionResult(BaseModel):
"""A reflection result from recall."""
id: str = Field(description="Unique reflection 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.
@@ -175,6 +253,7 @@ class ReflectResult(BaseModel):
],
"experience": [],
"opinion": [],
"mental-models": [],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
@@ -185,7 +264,7 @@ class ReflectResult(BaseModel):
text: str = Field(description="The formulated answer text")
based_on: dict[str, list[MemoryFact]] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
description="Facts used to formulate the answer, organized by type (world, experience, opinion, mental-models)"
)
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
structured_output: dict[str, Any] | None = Field(
@@ -196,6 +275,18 @@ class ReflectResult(BaseModel):
default=None,
description="Token usage metrics for the LLM calls made during this reflect operation.",
)
tool_trace: list[ToolCallTrace] = Field(
default_factory=list,
description="Trace of tool calls made during reflection. Only present when include.tool_calls is enabled.",
)
llm_trace: list[LLMCallTrace] = Field(
default_factory=list,
description="Trace of LLM calls made during reflection. Only present when include.tool_calls is enabled.",
)
directives_applied: list[DirectiveRef] = Field(
default_factory=list,
description="Directive mental models that were applied during this reflection.",
)
class Opinion(BaseModel):
@@ -259,3 +350,32 @@ class EntityState(BaseModel):
observations: list[EntityObservation] = Field(
default_factory=list, description="List of observations about this entity"
)
class MentalModel(BaseModel):
"""
A manually configured mental model for tracking specific topics/areas.
Mental models are user-defined focus areas that the agent should track
and maintain summaries for, unlike auto-extracted entities.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "team-dynamics",
"name": "Team Dynamics",
"description": "Track how the team collaborates, communication patterns, conflicts, and resolutions",
"summary": "The team has strong collaboration...",
"summary_updated_at": "2024-01-15T10:30:00Z",
"created_at": "2024-01-10T08:00:00Z",
}
}
)
id: str = Field(description="Unique identifier (alphanumeric lowercase)")
name: str = Field(description="Display name for the mental model")
description: str = Field(description="Prompt/directions for what to track and summarize")
summary: str | None = Field(None, description="Generated summary based on relevant facts")
summary_updated_at: str | None = Field(None, description="ISO format date when summary was last updated")
created_at: str = Field(description="ISO format date when the mental model was created")
@@ -1,5 +1,5 @@
"""
bank profile utilities for disposition and background management.
bank profile utilities for disposition and mission management.
"""
import json
@@ -27,19 +27,18 @@ class BankProfile(TypedDict):
name: str
disposition: DispositionTraits
background: str
mission: str
class BackgroundMergeResponse(BaseModel):
"""LLM response for background merge with disposition inference."""
class MissionMergeResponse(BaseModel):
"""LLM response for mission merge."""
background: str = Field(description="Merged background in first person perspective")
disposition: DispositionTraits = Field(description="Inferred disposition traits (skepticism, literalism, empathy)")
mission: str = Field(description="Merged mission in first person perspective")
async def get_bank_profile(pool, bank_id: str) -> BankProfile:
"""
Get bank profile (name, disposition + background).
Get bank profile (name, disposition + mission).
Auto-creates bank with default values if not exists.
Args:
@@ -47,13 +46,13 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
bank_id: bank IDentifier
Returns:
BankProfile with name, typed DispositionTraits, and background
BankProfile with name, typed DispositionTraits, and mission
"""
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
f"""
SELECT name, disposition, background
SELECT name, disposition, mission
FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id,
@@ -66,13 +65,15 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
disposition_data = json.loads(disposition_data)
return BankProfile(
name=row["name"], disposition=DispositionTraits(**disposition_data), background=row["background"]
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
)
# Bank doesn't exist, create with defaults
await conn.execute(
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, background)
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, mission)
VALUES ($1, $2, $3::jsonb, $4)
ON CONFLICT (bank_id) DO NOTHING
""",
@@ -82,7 +83,7 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
"",
)
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), background="")
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission="")
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
@@ -110,244 +111,121 @@ async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int
)
async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, update_disposition: bool = True) -> dict:
async def set_bank_mission(pool, bank_id: str, mission: str) -> None:
"""
Merge new background information with existing background using LLM.
Normalizes to first person ("I") and resolves conflicts.
Optionally infers disposition traits from the merged background.
Set bank mission (replacing any existing mission).
Args:
pool: Database connection pool
llm_config: LLM configuration for background merging
bank_id: bank IDentifier
new_info: New background information to add/merge
update_disposition: If True, infer Big Five traits from background (default: True)
mission: The mission text
"""
# Ensure bank exists first
await get_bank_profile(pool, bank_id)
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET mission = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
mission,
)
async def merge_bank_mission(pool, llm_config, bank_id: str, new_info: str) -> dict:
"""
Merge new mission information with existing mission using LLM.
Normalizes to first person ("I") and resolves conflicts.
Args:
pool: Database connection pool
llm_config: LLM configuration for mission merging
bank_id: bank IDentifier
new_info: New mission information to add/merge
Returns:
Dict with 'background' (str) and optionally 'disposition' (dict) keys
Dict with 'mission' (str) key
"""
# Get current profile
profile = await get_bank_profile(pool, bank_id)
current_background = profile["background"]
current_mission = profile["mission"]
# Use LLM to merge backgrounds and optionally infer disposition
result = await _llm_merge_background(llm_config, current_background, new_info, infer_disposition=update_disposition)
# Use LLM to merge missions
result = await _llm_merge_mission(llm_config, current_mission, new_info)
merged_background = result["background"]
inferred_disposition = result.get("disposition")
merged_mission = result["mission"]
# Update in database
async with acquire_with_retry(pool) as conn:
if inferred_disposition:
# Update both background and disposition
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET background = $2,
disposition = $3::jsonb,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_background,
json.dumps(inferred_disposition),
)
else:
# Update only background
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_background,
)
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET mission = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_mission,
)
response = {"background": merged_background}
if inferred_disposition:
response["disposition"] = inferred_disposition
return response
return {"mission": merged_mission}
async def _llm_merge_background(llm_config, current: str, new_info: str, infer_disposition: bool = False) -> dict:
async def _llm_merge_mission(llm_config, current: str, new_info: str) -> dict:
"""
Use LLM to intelligently merge background information.
Optionally infer Big Five disposition traits from the merged background.
Use LLM to intelligently merge mission information.
Args:
llm_config: LLM configuration to use
current: Current background text
current: Current mission text
new_info: New information to merge
infer_disposition: If True, also infer disposition traits
Returns:
Dict with 'background' (str) and optionally 'disposition' (dict) keys
Dict with 'mission' (str) key
"""
if infer_disposition:
prompt = f"""You are helping maintain a memory bank's background/profile and infer their disposition. You MUST respond with ONLY valid JSON.
prompt = f"""You are helping maintain an agent's mission statement.
Current background: {current if current else "(empty)"}
Current mission: {current if current else "(empty)"}
New information to add: {new_info}
Instructions:
1. Merge the new information with the current background
2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old
3. Keep additions that don't conflict
4. Output in FIRST PERSON ("I") perspective
5. Be concise - keep merged background under 500 characters
6. Infer disposition traits from the merged background (each 1-5 integer):
- Skepticism: 1-5 (1=trusting, takes things at face value; 5=skeptical, questions everything)
- Literalism: 1-5 (1=flexible interpretation, reads between lines; 5=literal, exact interpretation)
- Empathy: 1-5 (1=detached, focuses on facts; 5=empathetic, considers emotional context)
CRITICAL: You MUST respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations. Just the JSON.
Format:
{{
"background": "the merged background text in first person",
"disposition": {{
"skepticism": 3,
"literalism": 3,
"empathy": 3
}}
}}
Trait inference examples:
- "I'm a lawyer" → skepticism: 4, literalism: 5, empathy: 2
- "I'm a therapist" → skepticism: 2, literalism: 2, empathy: 5
- "I'm an engineer" → skepticism: 3, literalism: 4, empathy: 3
- "I've been burned before by trusting people" → skepticism: 5, literalism: 3, empathy: 3
- "I try to understand what people really mean" → skepticism: 3, literalism: 2, empathy: 4
- "I take contracts very seriously" → skepticism: 4, literalism: 5, empathy: 2"""
else:
prompt = f"""You are helping maintain a memory bank's background/profile.
Current background: {current if current else "(empty)"}
New information to add: {new_info}
Instructions:
1. Merge the new information with the current background
2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old
1. Merge the new information with the current mission
2. If there are conflicts, the NEW information overwrites the old
3. Keep additions that don't conflict
4. Output in FIRST PERSON ("I") perspective
5. Be concise - keep it under 500 characters
6. Return ONLY the merged background text, no explanations
6. Return ONLY the merged mission text, no explanations
Merged background:"""
Merged mission:"""
try:
# Prepare messages
messages = [{"role": "user", "content": prompt}]
if infer_disposition:
# Use structured output with Pydantic model for disposition inference
try:
parsed = await llm_config.call(
messages=messages,
response_format=BackgroundMergeResponse,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192,
)
logger.info(f"Successfully got structured response: background={parsed.background[:100]}")
# Convert Pydantic model to dict format
return {"background": parsed.background, "disposition": parsed.disposition.model_dump()}
except Exception as e:
logger.warning(f"Structured output failed, falling back to manual parsing: {e}")
# Fall through to manual parsing below
# Manual parsing fallback or non-disposition merge
content = await llm_config.call(
messages=messages, scope="bank_background", temperature=0.3, max_completion_tokens=8192
messages=messages, scope="bank_mission", temperature=0.3, max_completion_tokens=8192
)
logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}")
logger.info(f"LLM response for mission merge (first 500 chars): {content[:500]}")
if infer_disposition:
# Parse JSON response - try multiple extraction methods
result = None
# Method 1: Direct parse
try:
result = json.loads(content)
logger.info("Successfully parsed JSON directly")
except json.JSONDecodeError:
pass
# Method 2: Extract from markdown code blocks
if result is None:
# Remove markdown code blocks
code_block_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
if code_block_match:
try:
result = json.loads(code_block_match.group(1))
logger.info("Successfully extracted JSON from markdown code block")
except json.JSONDecodeError:
pass
# Method 3: Find nested JSON structure
if result is None:
# Look for JSON object with nested structure
json_match = re.search(
r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL
)
if json_match:
try:
result = json.loads(json_match.group())
logger.info("Successfully extracted JSON using nested pattern")
except json.JSONDecodeError:
pass
# All parsing methods failed - use fallback
if result is None:
logger.warning(f"Failed to extract JSON from LLM response. Raw content: {content[:200]}")
# Fallback: use new_info as background with default disposition
return {
"background": new_info if new_info else current if current else "",
"disposition": DEFAULT_DISPOSITION.copy(),
}
# Validate disposition values
disposition = result.get("disposition", {})
for key in ["skepticism", "literalism", "empathy"]:
if key not in disposition:
disposition[key] = 3 # Default to neutral
else:
# Clamp to [1, 5] and convert to int
disposition[key] = max(1, min(5, int(disposition[key])))
result["disposition"] = disposition
# Ensure background exists
if "background" not in result or not result["background"]:
result["background"] = new_info if new_info else ""
return result
else:
# Just background merge
merged = content
if not merged or merged.lower() in ["(empty)", "none", "n/a"]:
merged = new_info if new_info else ""
return {"background": merged}
merged = content.strip()
if not merged or merged.lower() in ["(empty)", "none", "n/a"]:
merged = new_info if new_info else ""
return {"mission": merged}
except Exception as e:
logger.error(f"Error merging background with LLM: {e}")
logger.error(f"Error merging mission with LLM: {e}")
# Fallback: just append new info
if current:
merged = f"{current} {new_info}".strip()
else:
merged = new_info
result = {"background": merged}
if infer_disposition:
result["disposition"] = DEFAULT_DISPOSITION.copy()
return result
return {"mission": merged}
async def list_banks(pool) -> list:
@@ -358,12 +236,12 @@ async def list_banks(pool) -> list:
pool: Database connection pool
Returns:
List of dicts with bank_id, name, disposition, background, created_at, updated_at
List of dicts with bank_id, name, disposition, mission, created_at, updated_at
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT bank_id, name, disposition, background, created_at, updated_at
SELECT bank_id, name, disposition, mission, created_at, updated_at
FROM {fq_table("banks")}
ORDER BY updated_at DESC
"""
@@ -381,7 +259,7 @@ async def list_banks(pool) -> list:
"bank_id": row["bank_id"],
"name": row["name"],
"disposition": disposition_data,
"background": row["background"],
"mission": row["mission"] or "",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
@@ -114,11 +114,8 @@ 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", "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"
relation_type: Literal["caused_by"] = Field(
description="How this fact relates to the target: 'caused_by' = this fact was caused by the target"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0)",
@@ -141,11 +138,8 @@ 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", "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"
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"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
@@ -441,7 +435,7 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
# 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 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.
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.
{fact_types_instruction}
@@ -662,7 +656,7 @@ CAUSAL RELATIONSHIPS
══════════════════════════════════════════════════════════════════════════
Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index.
Types: "caused_by", "enabled_by", "prevented_by"
Type: "caused_by" (this fact was caused by the target fact)
Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 0: Lost job, causal_relations: null
@@ -823,7 +817,8 @@ Text:
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
fact_type = llm_fact.get("fact_type")
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience" for storage
if fact_type == "assistant":
@@ -840,7 +835,10 @@ Text:
else:
# Default to 'world' if we can't determine
fact_type = "world"
logger.warning(f"Fact {i}: defaulting to 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})"
)
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -1268,6 +1266,7 @@ async def extract_facts_from_contents(
# mentioned_at: always the event_date (when the conversation/document occurred)
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
)
extracted_facts.append(extracted_fact)
@@ -41,10 +41,10 @@ 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(fact.fact_text)
@@ -60,21 +60,35 @@ async def insert_facts_batch(
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
document_ids.append(fact.document_id if fact.document_id else document_id)
# Convert tags to JSON string for proper batch insertion (PostgreSQL unnest doesn't handle 2D arrays well)
tags_list.append(json.dumps(fact.tags if fact.tags else []))
# Batch insert all facts
# Note: tags are passed as JSON strings and converted back to varchar[] via jsonb_array_elements_text + array_agg
results = await conn.fetch(
f"""
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id)
SELECT $1, * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::int[], $12::jsonb[], $13::text[], $14::text[]
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[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, 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)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
)
FROM input_data
RETURNING id
""",
bank_id,
@@ -87,10 +101,10 @@ async def insert_facts_batch(
contexts,
fact_types,
confidence_scores,
access_counts,
metadata_jsons,
chunk_ids,
document_ids,
tags_list,
)
unit_ids = [str(row["id"]) for row in results]
@@ -109,7 +123,7 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
"""
await conn.execute(
f"""
INSERT INTO {fq_table("banks")} (bank_id, disposition, background)
INSERT INTO {fq_table("banks")} (bank_id, disposition, mission)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (bank_id) DO UPDATE
SET updated_at = NOW()
@@ -121,7 +135,13 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
async def handle_document_tracking(
conn, bank_id: str, document_id: str, combined_content: str, is_first_batch: bool, retain_params: dict | None = None
conn,
bank_id: str,
document_id: str,
combined_content: str,
is_first_batch: bool,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
) -> None:
"""
Handle document tracking in the database.
@@ -133,6 +153,7 @@ async def handle_document_tracking(
combined_content: Combined content text from all content items
is_first_batch: Whether this is the first batch (for chunked operations)
retain_params: Optional parameters passed during retain (context, event_date, etc.)
document_tags: Optional list of tags to associate with the document
"""
import hashlib
@@ -149,13 +170,14 @@ async def handle_document_tracking(
# Insert document (or update if exists from concurrent operations)
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id, bank_id) DO UPDATE
SET original_text = EXCLUDED.original_text,
content_hash = EXCLUDED.content_hash,
metadata = EXCLUDED.metadata,
retain_params = EXCLUDED.retain_params,
tags = EXCLUDED.tags,
updated_at = NOW()
""",
document_id,
@@ -164,4 +186,5 @@ async def handle_document_tracking(
content_hash,
json.dumps({}), # Empty metadata dict
json.dumps(retain_params) if retain_params else None,
document_tags or [],
)
@@ -754,17 +754,14 @@ 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: "causes", "caused_by", "enables", or "prevents"
- relation_type: "caused_by"
- strength: Float in [0.0, 1.0] representing relationship strength
Returns:
Number of causal links created
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)
Causal link type:
- "caused_by": This fact was caused by the target fact
"""
if not unit_ids or not causal_relations_per_fact:
return 0
@@ -787,8 +784,8 @@ async def create_causal_links_batch(
relation_type = relation["relation_type"]
strength = relation.get("strength", 1.0)
# Validate relation_type - must match database constraint
valid_types = {"causes", "caused_by", "enables", "prevents"}
# Validate relation_type - only "caused_by" is supported (DB constraint)
valid_types = {"caused_by"}
if relation_type not in valid_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
@@ -1,254 +0,0 @@
"""
Observation regeneration for retain pipeline.
Regenerates entity observations as part of the retain transaction.
"""
import logging
import time
import uuid
from datetime import UTC, datetime
from ...config import get_config
from ..memory_engine import fq_table
from ..search import observation_utils
from . import embedding_utils
from .types import EntityLink
logger = logging.getLogger(__name__)
def utcnow():
"""Get current UTC time."""
return datetime.now(UTC)
# Simple dataclass-like container for facts (avoid importing from memory_engine)
class MemoryFactForObservation:
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: str | None):
self.id = id
self.text = text
self.fact_type = fact_type
self.context = context
self.occurred_start = occurred_start
async def regenerate_observations_batch(
conn, embeddings_model, llm_config, bank_id: str, entity_links: list[EntityLink], log_buffer: list[str] = None
) -> None:
"""
Regenerate observations for top entities in this batch.
Called INSIDE the retain transaction for atomicity - if observations
fail, the entire retain batch is rolled back.
Args:
conn: Database connection (from the retain transaction)
embeddings_model: Embeddings model for generating observation embeddings
llm_config: LLM configuration for observation extraction
bank_id: Bank identifier
entity_links: Entity links from this batch
log_buffer: Optional log buffer for timing
"""
config = get_config()
TOP_N_ENTITIES = config.observation_top_entities
MIN_FACTS_THRESHOLD = config.observation_min_facts
if not entity_links:
return
# Count mentions per entity in this batch
entity_mention_counts: dict[str, int] = {}
for link in entity_links:
if link.entity_id:
entity_id = str(link.entity_id)
entity_mention_counts[entity_id] = entity_mention_counts.get(entity_id, 0) + 1
if not entity_mention_counts:
return
# Sort by mention count descending and take top N
sorted_entities = sorted(entity_mention_counts.items(), key=lambda x: x[1], reverse=True)
entities_to_process = [e[0] for e in sorted_entities[:TOP_N_ENTITIES]]
obs_start = time.time()
# Convert to UUIDs
entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entities_to_process]
# Batch query for entity names
entity_rows = await conn.fetch(
f"""
SELECT id, canonical_name FROM {fq_table("entities")}
WHERE id = ANY($1) AND bank_id = $2
""",
entity_uuids,
bank_id,
)
entity_names = {row["id"]: row["canonical_name"] for row in entity_rows}
# Batch query for fact counts
fact_counts = await conn.fetch(
f"""
SELECT ue.entity_id, COUNT(*) as cnt
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("memory_units")} mu ON ue.unit_id = mu.id
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
GROUP BY ue.entity_id
""",
entity_uuids,
bank_id,
)
entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts}
# Filter entities that meet the threshold
entities_with_names = []
for entity_id in entities_to_process:
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
if entity_uuid not in entity_names:
continue
fact_count = entity_fact_counts.get(entity_uuid, 0)
if fact_count >= MIN_FACTS_THRESHOLD:
entities_with_names.append((entity_id, entity_names[entity_uuid]))
if not entities_with_names:
return
# Process entities SEQUENTIALLY (asyncpg doesn't allow concurrent queries on same connection)
# We must use the same connection to stay in the retain transaction
total_observations = 0
for entity_id, entity_name in entities_with_names:
try:
obs_ids = await _regenerate_entity_observations(
conn, embeddings_model, llm_config, bank_id, entity_id, entity_name
)
total_observations += len(obs_ids)
except Exception as e:
logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}")
obs_time = time.time() - obs_start
if log_buffer is not None:
log_buffer.append(
f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s"
)
async def _regenerate_entity_observations(
conn, embeddings_model, llm_config, bank_id: str, entity_id: str, entity_name: str
) -> list[str]:
"""
Regenerate observations for a single entity.
Uses the provided connection (part of retain transaction).
Args:
conn: Database connection (from the retain transaction)
embeddings_model: Embeddings model
llm_config: LLM configuration
bank_id: Bank identifier
entity_id: Entity UUID
entity_name: Canonical name of the entity
Returns:
List of created observation IDs
"""
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
# Get all facts mentioning this entity (exclude observations themselves)
rows = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND ue.entity_id = $2
AND mu.fact_type IN ('world', 'experience')
ORDER BY mu.occurred_start DESC
LIMIT 50
""",
bank_id,
entity_uuid,
)
if not rows:
return []
# Convert to fact objects for observation extraction
facts = []
for row in rows:
occurred_start = row["occurred_start"].isoformat() if row["occurred_start"] else None
facts.append(
MemoryFactForObservation(
id=str(row["id"]),
text=row["text"],
fact_type=row["fact_type"],
context=row["context"],
occurred_start=occurred_start,
)
)
# Extract observations using LLM
observations = await observation_utils.extract_observations_from_facts(llm_config, entity_name, facts)
if not observations:
return []
# Delete old observations for this entity
await conn.execute(
f"""
DELETE FROM {fq_table("memory_units")}
WHERE id IN (
SELECT mu.id
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND ue.entity_id = $2
)
""",
bank_id,
entity_uuid,
)
# Generate embeddings for new observations
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, observations)
# Insert new observations
current_time = utcnow()
created_ids = []
for obs_text, embedding in zip(observations, embeddings):
result = await conn.fetchrow(
f"""
INSERT INTO {fq_table("memory_units")} (
bank_id, text, embedding, context, event_date,
occurred_start, occurred_end, mentioned_at,
fact_type, access_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0)
RETURNING id
""",
bank_id,
obs_text,
str(embedding),
f"observation about {entity_name}",
current_time,
current_time,
current_time,
current_time,
)
obs_id = str(result["id"])
created_ids.append(obs_id)
# Link observation to entity
await conn.execute(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
""",
uuid.UUID(obs_id),
entity_uuid,
)
return created_ids
@@ -9,7 +9,6 @@ import time
import uuid
from datetime import UTC, datetime
from ...config import get_config
from ..db_utils import acquire_with_retry
from . import bank_utils
@@ -28,9 +27,8 @@ from . import (
fact_extraction,
fact_storage,
link_creation,
observation_regeneration,
)
from .types import ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
from .types import EntityLink, ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
logger = logging.getLogger(__name__)
@@ -40,7 +38,6 @@ async def retain_batch(
embeddings_model,
llm_config,
entity_resolver,
task_backend,
format_date_fn,
duplicate_checker_fn,
bank_id: str,
@@ -49,6 +46,7 @@ async def retain_batch(
is_first_batch: bool = True,
fact_type_override: str | None = None,
confidence_score: float | None = None,
document_tags: list[str] | None = None,
) -> tuple[list[list[str]], TokenUsage]:
"""
Process a batch of content through the retain pipeline.
@@ -58,7 +56,6 @@ async def retain_batch(
embeddings_model: Embeddings model for generating embeddings
llm_config: LLM configuration for fact extraction
entity_resolver: Entity resolver for entity processing
task_backend: Task backend for background jobs
format_date_fn: Function to format datetime to readable string
duplicate_checker_fn: Function to check for duplicate facts
bank_id: Bank identifier
@@ -67,6 +64,7 @@ async def retain_batch(
is_first_batch: Whether this is the first batch
fact_type_override: Override fact type for all facts
confidence_score: Confidence score for opinions
document_tags: Tags applied to all items in this batch
Returns:
Tuple of (unit ID lists, token usage for fact extraction)
@@ -88,12 +86,16 @@ async def retain_batch(
# Convert dicts to RetainContent objects
contents = []
for item in contents_dicts:
# Merge item-level tags with document-level tags
item_tags = item.get("tags", []) or []
merged_tags = list(set(item_tags + (document_tags or [])))
content = RetainContent(
content=item["content"],
context=item.get("context", ""),
event_date=item.get("event_date") or utcnow(),
metadata=item.get("metadata", {}),
entities=item.get("entities", []),
tags=merged_tags,
)
contents.append(content)
@@ -131,7 +133,7 @@ async def retain_batch(
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
)
else:
# Check for per-item document_ids
@@ -159,7 +161,7 @@ async def retain_batch(
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params, document_tags
)
total_time = time.time() - start_time
@@ -225,7 +227,7 @@ async def retain_batch(
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
)
document_ids_added.append(document_id)
doc_id_mapping[None] = document_id # For backwards compatibility
@@ -269,7 +271,13 @@ async def retain_batch(
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, actual_doc_id, combined_content, is_first_batch, retain_params
conn,
bank_id,
actual_doc_id,
combined_content,
is_first_batch,
retain_params,
document_tags,
)
document_ids_added.append(actual_doc_id)
@@ -396,27 +404,9 @@ async def retain_batch(
causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Regenerate observations - sync (in transaction) or async (background task)
config = get_config()
if config.retain_observations_async:
# Queue for async processing after transaction commits
entity_ids_for_async = list(set(link.entity_id for link in entity_links)) if entity_links else []
log_buffer.append(
f"[11] Observations: queued {len(entity_ids_for_async)} entities for async processing"
)
else:
# Run synchronously inside transaction for atomicity
await observation_regeneration.regenerate_observations_batch(
conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
)
entity_ids_for_async = []
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids)
# Trigger background tasks AFTER transaction commits
await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts, entity_ids_for_async)
# Log final summary
total_time = time.time() - start_time
log_buffer.append(f"{'=' * 60}")
@@ -458,35 +448,3 @@ def _map_results_to_contents(
result_unit_ids.append(content_unit_ids)
return result_unit_ids
async def _trigger_background_tasks(
task_backend,
bank_id: str,
unit_ids: list[str],
facts: list[ProcessedFact],
entity_ids_for_observations: list[str] | None = None,
) -> None:
"""Trigger background tasks after transaction commits."""
# Trigger opinion reinforcement if there are entities
fact_entities = [[e.name for e in fact.entities] for fact in facts]
if any(fact_entities):
await task_backend.submit_task(
{
"type": "reinforce_opinion",
"bank_id": bank_id,
"created_unit_ids": unit_ids,
"unit_texts": [fact.fact_text for fact in facts],
"unit_entities": fact_entities,
}
)
# Trigger observation regeneration if async mode is enabled
if entity_ids_for_observations:
await task_backend.submit_task(
{
"type": "regenerate_observations",
"bank_id": bank_id,
"entity_ids": entity_ids_for_observations,
}
)
@@ -21,6 +21,7 @@ class RetainContentDict(TypedDict, total=False):
metadata: Custom key-value metadata (optional)
document_id: Document ID for this content item (optional)
entities: User-provided entities to merge with extracted entities (optional)
tags: Visibility scope tags for this content item (optional)
"""
content: str # Required
@@ -29,6 +30,7 @@ class RetainContentDict(TypedDict, total=False):
metadata: dict[str, str]
document_id: str
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
tags: list[str] # Visibility scope tags
def _now_utc() -> datetime:
@@ -49,6 +51,7 @@ class RetainContent:
event_date: datetime = field(default_factory=_now_utc)
metadata: dict[str, str] = field(default_factory=dict)
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
tags: list[str] = field(default_factory=list) # Visibility scope tags
@dataclass
@@ -83,10 +86,10 @@ class CausalRelation:
"""
Causal relationship between facts.
Represents how one fact causes, enables, or prevents another.
Represents how one fact was caused by another.
"""
relation_type: str # "causes", "enables", "prevents", "caused_by"
relation_type: str # "caused_by"
target_fact_index: int # Index of the target fact in the batch
strength: float = 1.0 # Strength of the causal relationship
@@ -113,6 +116,7 @@ class ExtractedFact:
context: str = ""
mentioned_at: datetime | None = None
metadata: dict[str, str] = field(default_factory=dict)
tags: list[str] = field(default_factory=list) # Visibility scope tags
@dataclass
@@ -158,6 +162,9 @@ class ProcessedFact:
# Track which content this fact came from (for user entity merging)
content_index: int = 0
# Visibility scope tags
tags: list[str] = field(default_factory=list)
@property
def is_duplicate(self) -> bool:
"""Check if this fact was marked as a duplicate."""
@@ -201,6 +208,7 @@ class ProcessedFact:
causal_relations=extracted_fact.causal_relations,
chunk_id=chunk_id,
content_index=extracted_fact.content_index,
tags=extracted_fact.tags,
)
@@ -232,6 +240,7 @@ class RetainBatch:
document_id: str | None = None
fact_type_override: str | None = None
confidence_score: float | None = None
document_tags: list[str] = field(default_factory=list) # Tags applied to all items
# Extracted data (populated during processing)
extracted_facts: list[ExtractedFact] = field(default_factory=list)
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .tags import TagsMatch, filter_results_by_tags
from .types import MPFPTimings, RetrievalResult
logger = logging.getLogger(__name__)
@@ -43,6 +44,8 @@ class GraphRetriever(ABC):
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None, # TypedAdjacency, optional pre-loaded graph
tags: list[str] | None = None, # Visibility scope tags for filtering
tags_match: TagsMatch = "any", # How to match tags: 'any' (OR) or 'all' (AND)
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
"""
Retrieve relevant facts via graph traversal.
@@ -57,6 +60,7 @@ class GraphRetriever(ABC):
semantic_seeds: Pre-computed semantic entry points (from semantic retrieval)
temporal_seeds: Pre-computed temporal entry points (from temporal retrieval)
adjacency: Pre-loaded typed adjacency graph (optional, for MPFP)
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
Tuple of (List of RetrievalResult with activation scores, optional timing info)
@@ -114,6 +118,8 @@ class BFSGraphRetriever(GraphRetriever):
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None, # Not used by BFS
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
"""
Retrieve facts using BFS spreading activation.
@@ -129,7 +135,9 @@ class BFSGraphRetriever(GraphRetriever):
for interface compatibility but not used.
"""
async with acquire_with_retry(pool) as conn:
results = await self._retrieve_with_conn(conn, query_embedding_str, bank_id, fact_type, budget)
results = await self._retrieve_with_conn(
conn, query_embedding_str, bank_id, fact_type, budget, tags=tags, tags_match=tags_match
)
return results, None
async def _retrieve_with_conn(
@@ -139,33 +147,46 @@ class BFSGraphRetriever(GraphRetriever):
bank_id: str,
fact_type: str,
budget: int,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> list[RetrievalResult]:
"""Internal implementation with connection."""
from .tags import build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
params = [query_embedding_str, bank_id, fact_type, self.entry_point_threshold, self.entry_point_limit]
if tags:
params.append(tags)
# Step 1: Find entry points
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
{tags_clause}
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str,
bank_id,
fact_type,
self.entry_point_threshold,
self.entry_point_limit,
*params,
)
if not entry_points:
logger.debug(
f"[BFS] No entry points found for fact_type={fact_type} (tags={tags}, tags_match={tags_match})"
)
return []
logger.debug(
f"[BFS] Found {len(entry_points)} entry points for fact_type={fact_type} "
f"(tags={tags}, tags_match={tags_match})"
)
# Step 2: BFS spreading activation
visited = set()
results = []
@@ -195,8 +216,8 @@ 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.access_count, mu.embedding, mu.fact_type,
mu.document_id, mu.chunk_id,
mu.mentioned_at, 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
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
@@ -236,4 +257,8 @@ class BFSGraphRetriever(GraphRetriever):
neighbor_result = RetrievalResult.from_db_row(dict(n))
queue.append((neighbor_result, new_activation))
# Apply tags filtering (BFS may traverse into memories that don't match tags criteria)
if tags:
results = filter_results_by_tags(results, tags, match=tags_match)
return results
@@ -18,6 +18,7 @@ import time
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
from .tags import TagsMatch, filter_results_by_tags
from .types import MPFPTimings, RetrievalResult
logger = logging.getLogger(__name__)
@@ -30,26 +31,32 @@ async def _find_semantic_seeds(
fact_type: str,
limit: int = 20,
threshold: float = 0.3,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> list[RetrievalResult]:
"""Find semantic seeds via embedding search."""
from .tags import build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
params = [query_embedding_str, bank_id, fact_type, threshold, limit]
if tags:
params.append(tags)
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
{tags_clause}
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str,
bank_id,
fact_type,
threshold,
limit,
*params,
)
return [RetrievalResult.from_db_row(dict(r)) for r in rows]
@@ -95,6 +102,8 @@ class LinkExpansionRetriever(GraphRetriever):
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
"""
Retrieve facts by expanding links from seeds.
@@ -109,6 +118,7 @@ class LinkExpansionRetriever(GraphRetriever):
semantic_seeds: Pre-computed semantic entry points
temporal_seeds: Pre-computed temporal entry points
adjacency: Unused, kept for interface compatibility
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
Tuple of (results, timings)
@@ -125,15 +135,27 @@ class LinkExpansionRetriever(GraphRetriever):
else:
seeds_start = time.time()
all_seeds = await _find_semantic_seeds(
conn, query_embedding_str, bank_id, fact_type, limit=20, threshold=0.3
conn,
query_embedding_str,
bank_id,
fact_type,
limit=20,
threshold=0.3,
tags=tags,
tags_match=tags_match,
)
timings.seeds_time = time.time() - seeds_start
logger.debug(
f"[LinkExpansion] Found {len(all_seeds)} semantic seeds for fact_type={fact_type} "
f"(tags={tags}, tags_match={tags_match})"
)
# Add temporal seeds if provided
if temporal_seeds:
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})
@@ -146,8 +168,8 @@ class LinkExpansionRetriever(GraphRetriever):
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.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
@@ -171,8 +193,8 @@ class LinkExpansionRetriever(GraphRetriever):
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.access_count, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id,
mu.occurred_end, mu.mentioned_at, 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
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
@@ -219,6 +241,10 @@ class LinkExpansionRetriever(GraphRetriever):
result.activation = row["score"]
results.append(result)
# Apply tags filtering (graph expansion may reach untagged memories)
if tags:
results = filter_results_by_tags(results, tags, match=tags_match)
timings.result_count = len(results)
timings.traverse = time.time() - start_time
@@ -23,6 +23,7 @@ from dataclasses import dataclass, field
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
from .tags import TagsMatch
from .types import MPFPTimings, RetrievalResult
logger = logging.getLogger(__name__)
@@ -448,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, access_count, embedding, fact_type, document_id, chunk_id
mentioned_at, embedding, fact_type, document_id, chunk_id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND fact_type = $2
@@ -503,6 +504,8 @@ class MPFPGraphRetriever(GraphRetriever):
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None, # Ignored - kept for interface compatibility
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
"""
Retrieve facts using MPFP algorithm with lazy edge loading.
@@ -517,6 +520,7 @@ class MPFPGraphRetriever(GraphRetriever):
semantic_seeds: Pre-computed semantic entry points
temporal_seeds: Pre-computed temporal entry points
adjacency: Ignored (kept for interface compatibility)
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
Tuple of (List of RetrievalResult with activation scores, MPFPTimings)
@@ -532,8 +536,13 @@ class MPFPGraphRetriever(GraphRetriever):
# If no semantic seeds provided, fall back to finding our own
if not semantic_seed_nodes:
seeds_start = time.time()
semantic_seed_nodes = await self._find_semantic_seeds(pool, query_embedding_str, bank_id, fact_type)
semantic_seed_nodes = await self._find_semantic_seeds(
pool, query_embedding_str, bank_id, fact_type, tags=tags, tags_match=tags_match
)
timings.seeds_time = time.time() - seeds_start
logger.debug(
f"[MPFP] Found {len(semantic_seed_nodes)} semantic seeds for fact_type={fact_type} (tags={tags}, tags_match={tags_match})"
)
# Collect all pattern jobs
pattern_jobs = []
@@ -549,6 +558,9 @@ class MPFPGraphRetriever(GraphRetriever):
pattern_jobs.append((temporal_seed_nodes, pattern))
if not pattern_jobs:
logger.debug(
f"[MPFP] No pattern jobs (semantic_seeds={len(semantic_seed_nodes)}, temporal_seeds={len(temporal_seed_nodes)})"
)
return [], timings
timings.pattern_count = len(pattern_jobs)
@@ -587,6 +599,7 @@ class MPFPGraphRetriever(GraphRetriever):
timings.fusion = time.time() - step_start
if not fused:
logger.debug(f"[MPFP] No fused results after RRF fusion (pattern_count={len(pattern_results)})")
return [], timings
# Get top result IDs
@@ -596,6 +609,13 @@ class MPFPGraphRetriever(GraphRetriever):
step_start = time.time()
results = await fetch_memory_units_by_ids(pool, result_ids, fact_type)
timings.fetch = time.time() - step_start
# Filter results by tags (graph traversal may have picked up unfiltered memories)
if tags:
from .tags import filter_results_by_tags
results = filter_results_by_tags(results, tags, match=tags_match)
timings.result_count = len(results)
# Add activation scores from fusion
@@ -634,8 +654,17 @@ class MPFPGraphRetriever(GraphRetriever):
fact_type: str,
limit: int = 20,
threshold: float = 0.3,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> list[SeedNode]:
"""Fallback: find semantic seeds via embedding search."""
from .tags import build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
params = [query_embedding_str, bank_id, fact_type, threshold, limit]
if tags:
params.append(tags)
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
@@ -645,14 +674,11 @@ class MPFPGraphRetriever(GraphRetriever):
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
{tags_clause}
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str,
bank_id,
fact_type,
threshold,
limit,
*params,
)
return [SeedNode(node_id=str(r["id"]), score=r["similarity"]) for r in rows]
@@ -1,125 +0,0 @@
"""
Observation utilities for generating entity observations from facts.
Observations are objective facts synthesized from multiple memory facts
about an entity, without personality influence.
"""
import logging
from pydantic import BaseModel, Field
from ..response_models import MemoryFact
logger = logging.getLogger(__name__)
class Observation(BaseModel):
"""An observation about an entity."""
observation: str = Field(description="The observation text - a factual statement about the entity")
class ObservationExtractionResponse(BaseModel):
"""Response containing extracted observations."""
observations: list[Observation] = Field(default_factory=list, description="List of observations about the entity")
def format_facts_for_observation_prompt(facts: list[MemoryFact]) -> str:
"""Format facts as text for observation extraction prompt."""
import json
if not facts:
return "[]"
formatted = []
for fact in facts:
fact_obj = {"text": fact.text}
# Add context if available
if fact.context:
fact_obj["context"] = fact.context
# Add occurred_start if available
if fact.occurred_start:
fact_obj["occurred_at"] = fact.occurred_start
formatted.append(fact_obj)
return json.dumps(formatted, indent=2)
def build_observation_prompt(
entity_name: str,
facts_text: str,
) -> str:
"""Build the observation extraction prompt for the LLM."""
return f"""Based on the following facts about "{entity_name}", generate a list of key observations.
FACTS ABOUT {entity_name.upper()}:
{facts_text}
Your task: Synthesize the facts into clear, objective observations about {entity_name}.
GUIDELINES:
1. Each observation should be a factual statement about {entity_name}
2. Combine related facts into single observations where appropriate
3. Be objective - do not add opinions, judgments, or interpretations
4. Focus on what we KNOW about {entity_name}, not what we assume
5. Include observations about: identity, characteristics, roles, relationships, activities
6. Write in third person (e.g., "John is..." not "I think John is...")
7. If there are conflicting facts, note the most recent or most supported one
EXAMPLES of good observations:
- "John works at Google as a software engineer"
- "John is detail-oriented and methodical in his approach"
- "John collaborates frequently with Sarah on the AI project"
- "John joined the company in 2023"
EXAMPLES of bad observations (avoid these):
- "John seems like a good person" (opinion/judgment)
- "John probably likes his job" (assumption)
- "I believe John is reliable" (first-person opinion)
Generate 3-7 observations based on the available facts. If there are very few facts, generate fewer observations."""
def get_observation_system_message() -> str:
"""Get the system message for observation extraction."""
return "You are an objective observer synthesizing facts about an entity. Generate clear, factual observations without opinions or personality influence. Be concise and accurate."
async def extract_observations_from_facts(llm_config, entity_name: str, facts: list[MemoryFact]) -> list[str]:
"""
Extract observations from facts about an entity using LLM.
Args:
llm_config: LLM configuration to use
entity_name: Name of the entity to generate observations about
facts: List of facts mentioning the entity
Returns:
List of observation strings
"""
if not facts:
return []
facts_text = format_facts_for_observation_prompt(facts)
prompt = build_observation_prompt(entity_name, facts_text)
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_observation_system_message()},
{"role": "user", "content": prompt},
],
response_format=ObservationExtractionResponse,
scope="memory_extract_observation",
)
observations = [op.observation for op in result.observations]
return observations
except Exception as e:
logger.warning(f"Failed to extract observations for {entity_name}: {str(e)}")
return []
@@ -20,6 +20,7 @@ from ..memory_engine import fq_table
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
from .link_expansion_retrieval import LinkExpansionRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .tags import TagsMatch, build_tags_where_clause_simple
from .types import MPFPTimings, RetrievalResult
logger = logging.getLogger(__name__)
@@ -85,7 +86,12 @@ def set_default_graph_retriever(retriever: GraphRetriever) -> None:
async def retrieve_semantic(
conn, query_emb_str: str, bank_id: str, fact_type: str, limit: int
conn,
query_emb_str: str,
bank_id: str,
fact_type: str,
limit: int,
tags: list[str] | None = None,
) -> list[RetrievalResult]:
"""
Semantic retrieval via vector similarity.
@@ -96,31 +102,44 @@ async def retrieve_semantic(
agent_id: bank ID
fact_type: Fact type to filter
limit: Maximum results to return
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
List of RetrievalResult objects
"""
from .tags import TagsMatch, build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 5)
params = [query_emb_str, bank_id, fact_type, limit]
if tags:
params.append(tags)
results = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= 0.3
{tags_clause}
ORDER BY embedding <=> $1::vector
LIMIT $4
""",
query_emb_str,
bank_id,
fact_type,
limit,
*params,
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, limit: int) -> list[RetrievalResult]:
async def retrieve_bm25(
conn,
query_text: str,
bank_id: str,
fact_type: str,
limit: int,
tags: list[str] | None = None,
) -> list[RetrievalResult]:
"""
BM25 keyword retrieval via full-text search.
@@ -130,12 +149,15 @@ async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, lim
agent_id: bank ID
fact_type: Fact type to filter
limit: Maximum results to return
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
List of RetrievalResult objects
"""
import re
from .tags import TagsMatch, build_tags_where_clause_simple
# Sanitize query text: remove special characters that have meaning in tsquery
# Keep only alphanumeric characters and spaces
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
@@ -151,21 +173,24 @@ async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, lim
# This prevents empty results when some terms are missing
query_tsquery = " | ".join(tokens)
tags_clause = build_tags_where_clause_simple(tags, 5)
params = [query_tsquery, bank_id, fact_type, limit]
if tags:
params.append(tags)
results = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, 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
AND fact_type = $3
AND search_vector @@ to_tsquery('english', $1)
{tags_clause}
ORDER BY bm25_score DESC
LIMIT $4
""",
query_tsquery,
bank_id,
fact_type,
limit,
*params,
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
@@ -177,6 +202,8 @@ async def retrieve_semantic_bm25_combined(
bank_id: str,
fact_types: list[str],
limit: int,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]]:
"""
Combined semantic + BM25 retrieval for multiple fact types in a single query.
@@ -203,10 +230,14 @@ async def retrieve_semantic_bm25_combined(
# If no valid tokens for BM25, just run semantic
if not tokens:
tags_clause = build_tags_where_clause_simple(tags, 5, match=tags_match)
params = [query_emb_str, bank_id, fact_types, limit]
if tags:
params.append(tags)
results = await conn.fetch(
f"""
WITH semantic_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
NULL::float AS bm25_score,
'semantic' AS source,
@@ -216,16 +247,14 @@ async def retrieve_semantic_bm25_combined(
AND embedding IS NOT NULL
AND fact_type = ANY($3)
AND (1 - (embedding <=> $1::vector)) >= 0.3
{tags_clause}
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM semantic_ranked
WHERE rn <= $4
""",
query_emb_str,
bank_id,
fact_types,
limit,
*params,
)
# Group by fact_type
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {
@@ -241,12 +270,18 @@ async def retrieve_semantic_bm25_combined(
query_tsquery = " | ".join(tokens)
# Build tags clause - param 6 if tags provided
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
params = [query_emb_str, bank_id, fact_types, limit, query_tsquery]
if tags:
params.append(tags)
# Combined CTE query for both semantic and BM25 across all fact types
# Uses window functions to limit per fact_type per method
results = await conn.fetch(
f"""
WITH semantic_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
NULL::float AS bm25_score,
'semantic' AS source,
@@ -256,9 +291,10 @@ async def retrieve_semantic_bm25_combined(
AND embedding IS NOT NULL
AND fact_type = ANY($3)
AND (1 - (embedding <=> $1::vector)) >= 0.3
{tags_clause}
),
bm25_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, 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,
@@ -267,14 +303,15 @@ async def retrieve_semantic_bm25_combined(
WHERE bank_id = $2
AND fact_type = ANY($3)
AND search_vector @@ to_tsquery('english', $5)
{tags_clause}
),
semantic AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, 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, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM bm25_ranked WHERE rn <= $4
)
@@ -282,11 +319,7 @@ async def retrieve_semantic_bm25_combined(
UNION ALL
SELECT * FROM bm25
""",
query_emb_str,
bank_id,
fact_types,
limit,
query_tsquery,
*params,
)
# Group results by fact_type and source
@@ -313,6 +346,8 @@ async def retrieve_temporal_combined(
end_date: datetime,
budget: int,
semantic_threshold: float = 0.1,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> dict[str, list[RetrievalResult]]:
"""
Temporal retrieval for multiple fact types in a single query.
@@ -341,11 +376,17 @@ async def retrieve_temporal_combined(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
# Build tags clause
tags_clause = build_tags_where_clause_simple(tags, 7, match=tags_match)
params = [query_emb_str, bank_id, fact_types, start_date, end_date, semantic_threshold]
if tags:
params.append(tags)
# Batch query: Get entry points for ALL fact types at once with window function
entry_points = await conn.fetch(
f"""
WITH ranked_entries AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, 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")}
@@ -363,17 +404,13 @@ async def retrieve_temporal_combined(
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
AND (1 - (embedding <=> $1::vector)) >= $6
{tags_clause}
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, similarity
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags, similarity
FROM ranked_entries
WHERE rn <= 10
""",
query_emb_str,
bank_id,
fact_types,
start_date,
end_date,
semantic_threshold,
*params,
)
if not entry_points:
@@ -436,13 +473,20 @@ async def retrieve_temporal_combined(
budget_remaining = budget - len(ft_entry_points)
batch_size = 20
# Build tags clause for spreading (use param 6 since 1-5 are used)
spreading_tags_clause = build_tags_where_clause_simple(tags, 6, table_alias="mu.", match=tags_match)
while frontier and budget_remaining > 0:
batch_ids = frontier[:batch_size]
frontier = frontier[batch_size:]
spreading_params = [query_emb_str, batch_ids, ft, semantic_threshold, batch_size * 10]
if tags:
spreading_params.append(tags)
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.access_count, 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.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
@@ -453,14 +497,11 @@ async def retrieve_temporal_combined(
AND mu.fact_type = $3
AND mu.embedding IS NOT NULL
AND (1 - (mu.embedding <=> $1::vector)) >= $4
{spreading_tags_clause}
ORDER BY ml.weight DESC
LIMIT $5
""",
query_emb_str,
batch_ids,
ft,
semantic_threshold,
batch_size * 10,
*spreading_params,
)
for n in neighbors:
@@ -529,6 +570,7 @@ async def retrieve_temporal(
end_date: datetime,
budget: int,
semantic_threshold: float = 0.1,
tags: list[str] | None = None,
) -> list[RetrievalResult]:
"""
Temporal retrieval with spreading activation.
@@ -547,6 +589,7 @@ async def retrieve_temporal(
end_date: End of time range
budget: Node budget for spreading
semantic_threshold: Minimum semantic similarity to include
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
List of RetrievalResult objects with temporal scores
@@ -558,9 +601,16 @@ async def retrieve_temporal(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
from .tags import TagsMatch, build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 7)
params = [query_emb_str, bank_id, fact_type, start_date, end_date, semantic_threshold]
if tags:
params.append(tags)
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -580,15 +630,11 @@ async def retrieve_temporal(
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
AND (1 - (embedding <=> $1::vector)) >= $6
{tags_clause}
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, (embedding <=> $1::vector) ASC
LIMIT 10
""",
query_emb_str,
bank_id,
fact_type,
start_date,
end_date,
semantic_threshold,
*params,
)
if not entry_points:
@@ -645,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.access_count, 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.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
@@ -740,6 +786,7 @@ async def retrieve_parallel(
query_analyzer: Optional["QueryAnalyzer"] = None,
graph_retriever: GraphRetriever | None = None,
temporal_constraint: tuple | None = None, # Pre-extracted temporal constraint
tags: list[str] | None = None, # Visibility scope tags for filtering
) -> ParallelRetrievalResult:
"""
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
@@ -755,6 +802,7 @@ async def retrieve_parallel(
query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer)
graph_retriever: Graph retrieval strategy (defaults to configured retriever)
temporal_constraint: Pre-extracted temporal constraint (optional)
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
ParallelRetrievalResult with semantic, bm25, graph, temporal results and timings
@@ -775,6 +823,7 @@ async def retrieve_parallel(
retriever,
question_date,
query_analyzer,
tags=tags,
)
else:
# For BFS, extract temporal constraint upfront (legacy path)
@@ -785,7 +834,15 @@ async def retrieve_parallel(
query_text, reference_date=question_date, analyzer=query_analyzer
)
return await _retrieve_parallel_bfs(
pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever
pool,
query_text,
query_embedding_str,
bank_id,
fact_type,
thinking_budget,
temporal_constraint,
retriever,
tags=tags,
)
@@ -809,6 +866,7 @@ async def _retrieve_parallel_mpfp(
retriever: GraphRetriever,
question_date: datetime | None = None,
query_analyzer=None,
tags: list[str] | None = None,
) -> ParallelRetrievalResult:
"""
MPFP retrieval with true parallelization.
@@ -830,7 +888,9 @@ async def _retrieve_parallel_mpfp(
acquire_start = time.time()
async with acquire_with_retry(pool) as conn:
conn_wait = time.time() - acquire_start
results = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
results = await retrieve_semantic(
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget, tags=tags
)
return _TimedResult(results, time.time() - start, conn_wait)
async def run_bm25() -> _TimedResult:
@@ -839,7 +899,7 @@ async def _retrieve_parallel_mpfp(
acquire_start = time.time()
async with acquire_with_retry(pool) as conn:
conn_wait = time.time() - acquire_start
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget, tags=tags)
return _TimedResult(results, time.time() - start, conn_wait)
async def run_graph() -> tuple[list[RetrievalResult], float, MPFPTimings | None]:
@@ -857,6 +917,7 @@ async def _retrieve_parallel_mpfp(
query_text=query_text,
semantic_seeds=None, # Let MPFP find its own seeds
temporal_seeds=None, # Don't wait for temporal extraction
tags=tags,
)
return results, time.time() - start, mpfp_timing
@@ -962,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,
access_count, embedding, fact_type, document_id, chunk_id,
embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -1028,6 +1089,7 @@ async def _retrieve_parallel_bfs(
thinking_budget: int,
temporal_constraint: tuple | None,
retriever: GraphRetriever,
tags: list[str] | None = None,
) -> ParallelRetrievalResult:
"""BFS retrieval: all methods run in parallel (original behavior)."""
import time
@@ -1035,13 +1097,15 @@ async def _retrieve_parallel_bfs(
async def run_semantic() -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
results = await retrieve_semantic(
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget, tags=tags
)
return _TimedResult(results, time.time() - start)
async def run_bm25() -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget, tags=tags)
return _TimedResult(results, time.time() - start)
async def run_graph() -> _TimedResult:
@@ -1053,6 +1117,7 @@ async def _retrieve_parallel_bfs(
fact_type=fact_type,
budget=thinking_budget,
query_text=query_text,
tags=tags,
)
return _TimedResult(results, time.time() - start)
@@ -1068,6 +1133,7 @@ async def _retrieve_parallel_bfs(
tc_end,
budget=thinking_budget,
semantic_threshold=0.1,
tags=tags,
)
return _TimedResult(results, time.time() - start)
@@ -1122,6 +1188,8 @@ async def retrieve_all_fact_types_parallel(
question_date: datetime | None = None,
query_analyzer: Optional["QueryAnalyzer"] = None,
graph_retriever: GraphRetriever | None = None,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> MultiFactTypeRetrievalResult:
"""
Optimized retrieval for multiple fact types using batched queries.
@@ -1171,7 +1239,14 @@ async def retrieve_all_fact_types_parallel(
# Semantic + BM25 combined
semantic_bm25_results = await retrieve_semantic_bm25_combined(
conn, query_embedding_str, query_text, bank_id, fact_types, thinking_budget
conn,
query_embedding_str,
query_text,
bank_id,
fact_types,
thinking_budget,
tags=tags,
tags_match=tags_match,
)
semantic_bm25_time = time.time() - semantic_bm25_start
@@ -1188,6 +1263,8 @@ async def retrieve_all_fact_types_parallel(
tc_end,
budget=thinking_budget,
semantic_threshold=0.1,
tags=tags,
tags_match=tags_match,
)
temporal_time = time.time() - temporal_start
@@ -1206,6 +1283,8 @@ async def retrieve_all_fact_types_parallel(
query_text=query_text,
semantic_seeds=None,
temporal_seeds=None,
tags=tags,
tags_match=tags_match,
)
return ft, results, time.time() - graph_start, mpfp_timing
@@ -65,31 +65,6 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
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.
@@ -0,0 +1,172 @@
"""
Tags filtering utilities for retrieval.
Provides SQL building functions for filtering memories by tags.
Supports four matching modes via TagsMatch enum:
- "any": OR matching, includes untagged memories (default, backward compatible)
- "all": AND matching, includes untagged memories
- "any_strict": OR matching, excludes untagged memories
- "all_strict": AND matching, excludes untagged memories
OR matching (any/any_strict): Memory matches if ANY of its tags overlap with request tags
AND matching (all/all_strict): Memory matches if ALL request tags are present in its tags
"""
from typing import Literal
TagsMatch = Literal["any", "all", "any_strict", "all_strict"]
def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]:
"""
Parse TagsMatch into operator and include_untagged flag.
Returns:
Tuple of (operator, include_untagged)
- operator: "&&" for any/any_strict, "@>" for all/all_strict
- include_untagged: True for any/all, False for any_strict/all_strict
"""
if match == "any":
return "&&", True
elif match == "all":
return "@>", True
elif match == "any_strict":
return "&&", False
elif match == "all_strict":
return "@>", False
else:
# Default to "any" behavior
return "&&", True
def build_tags_where_clause(
tags: list[str] | None,
param_offset: int = 1,
table_alias: str = "",
match: TagsMatch = "any",
) -> tuple[str, list, int]:
"""
Build a SQL WHERE clause for filtering by tags.
Supports four matching modes:
- "any" (default): OR matching, includes untagged memories
- "all": AND matching, includes untagged memories
- "any_strict": OR matching, excludes untagged memories
- "all_strict": AND matching, excludes untagged memories
Args:
tags: List of tags to filter by. If None or empty, returns empty clause (no filtering).
param_offset: Starting parameter number for SQL placeholders (default 1).
table_alias: Optional table alias prefix (e.g., "mu." for "memory_units mu").
match: Matching mode. Defaults to "any".
Returns:
Tuple of (sql_clause, params, next_param_offset):
- sql_clause: SQL WHERE clause string
- params: List of parameter values to bind
- next_param_offset: Next available parameter number
Example:
>>> clause, params, next_offset = build_tags_where_clause(['user_a'], 3, 'mu.', 'any_strict')
>>> print(clause) # "AND mu.tags IS NOT NULL AND mu.tags != '{}' AND mu.tags && $3"
"""
if not tags:
return "", [], param_offset
column = f"{table_alias}tags" if table_alias else "tags"
operator, include_untagged = _parse_tags_match(match)
if include_untagged:
# Include untagged memories (NULL or empty array) OR matching tags
clause = f"AND ({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_offset})"
else:
# Strict: only memories with matching tags (exclude NULL and empty)
clause = f"AND {column} IS NOT NULL AND {column} != '{{}}' AND {column} {operator} ${param_offset}"
return clause, [tags], param_offset + 1
def build_tags_where_clause_simple(
tags: list[str] | None,
param_num: int,
table_alias: str = "",
match: TagsMatch = "any",
) -> str:
"""
Build a simple SQL WHERE clause for tags filtering.
This is a convenience version that returns just the clause string,
assuming the caller will add the tags array to their params list.
Args:
tags: List of tags to filter by. If None or empty, returns empty string.
param_num: Parameter number to use in the clause.
table_alias: Optional table alias prefix.
match: Matching mode. Defaults to "any".
Returns:
SQL clause string or empty string.
"""
if not tags:
return ""
column = f"{table_alias}tags" if table_alias else "tags"
operator, include_untagged = _parse_tags_match(match)
if include_untagged:
# Include untagged memories (NULL or empty array) OR matching tags
return f"AND ({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_num})"
else:
# Strict: only memories with matching tags (exclude NULL and empty)
return f"AND {column} IS NOT NULL AND {column} != '{{}}' AND {column} {operator} ${param_num}"
def filter_results_by_tags(
results: list,
tags: list[str] | None,
match: TagsMatch = "any",
) -> list:
"""
Filter retrieval results by tags in Python (for post-processing).
Used when SQL filtering isn't possible (e.g., graph traversal results).
Args:
results: List of RetrievalResult objects with a 'tags' attribute.
tags: List of tags to filter by. If None or empty, returns all results.
match: Matching mode. Defaults to "any".
Returns:
Filtered list of results.
"""
if not tags:
return results
_, include_untagged = _parse_tags_match(match)
is_any_match = match in ("any", "any_strict")
tags_set = set(tags)
filtered = []
for result in results:
result_tags = getattr(result, "tags", None)
# Check if untagged
is_untagged = result_tags is None or len(result_tags) == 0
if is_untagged:
if include_untagged:
filtered.append(result)
# else: skip untagged
else:
result_tags_set = set(result_tags)
if is_any_match:
# Any overlap
if result_tags_set & tags_set:
filtered.append(result)
else:
# All tags must be present
if tags_set <= result_tags_set:
filtered.append(result)
return filtered
@@ -3,31 +3,13 @@ Think operation utilities for formulating answers based on agent and world facts
"""
import logging
import re
from datetime import datetime
from pydantic import BaseModel, Field
from ..response_models import DispositionTraits, MemoryFact
logger = logging.getLogger(__name__)
class Opinion(BaseModel):
"""An opinion formed by the bank."""
opinion: str = Field(description="The opinion or perspective with reasoning included")
confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)")
class OpinionExtractionResponse(BaseModel):
"""Response containing extracted opinions."""
opinions: list[Opinion] = Field(
default_factory=list, description="List of opinions formed with their supporting reasons and confidence scores"
)
def describe_trait_level(value: int) -> str:
"""Convert trait value (1-5) to descriptive text."""
levels = {1: "very low", 2: "low", 3: "moderate", 4: "high", 5: "very high"}
@@ -93,17 +75,46 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
return json.dumps(formatted, indent=2)
def format_entity_summaries_for_prompt(entities: dict) -> str:
"""Format entity summaries for inclusion in the reflect prompt.
Args:
entities: Dict mapping entity name to EntityState objects
Returns:
Formatted string with entity summaries, or empty string if no summaries
"""
if not entities:
return ""
summaries = []
for name, state in entities.items():
# Get summary from observations (summary is stored as single observation)
if state.observations:
summary_text = state.observations[0].text
summaries.append(f"## {name}\n{summary_text}")
if not summaries:
return ""
return "\n\n".join(summaries)
def build_think_prompt(
agent_facts_text: str,
world_facts_text: str,
opinion_facts_text: str,
query: str,
name: str,
disposition: DispositionTraits,
background: str,
context: str | None = None,
entity_summaries_text: str | None = None,
) -> str:
"""Build the think prompt for the LLM."""
"""Build the think prompt for the LLM.
Note: opinion_facts_text parameter removed - opinions are now stored as mental models
and included via entity_summaries_text.
"""
disposition_desc = build_disposition_description(disposition)
name_section = f"""
@@ -125,6 +136,14 @@ Your background:
ADDITIONAL CONTEXT:
{context}
"""
entity_section = ""
if entity_summaries_text:
entity_section = f"""
KEY PEOPLE, PLACES & THINGS I KNOW ABOUT:
{entity_summaries_text}
"""
return f"""Here's what I know and have experienced:
@@ -135,14 +154,11 @@ MY IDENTITY & EXPERIENCES:
WHAT I KNOW ABOUT THE WORLD:
{world_facts_text}
MY EXISTING OPINIONS & BELIEFS:
{opinion_facts_text}
{context_section}{name_section}{disposition_desc}{background_section}
{entity_section}{context_section}{name_section}{disposition_desc}{background_section}
QUESTION: {query}
Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, and personal traits to give you my honest perspective."""
def get_system_message(disposition: DispositionTraits) -> str:
@@ -175,122 +191,11 @@ def get_system_message(disposition: DispositionTraits) -> str:
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting. IMPORTANT: Detect the language of the question and respond in the SAME language. Do not translate to English if the question is in another language."
async def extract_opinions_from_text(llm_config, text: str, query: str) -> list[Opinion]:
"""
Extract opinions with reasons and confidence from text using LLM.
Args:
llm_config: LLM configuration to use
text: Text to extract opinions from
query: The original query that prompted this response
Returns:
List of Opinion objects with text and confidence
"""
extraction_prompt = f"""Extract any NEW opinions or perspectives from the answer below and rewrite them in FIRST-PERSON as if YOU are stating the opinion directly.
ORIGINAL QUESTION:
{query}
ANSWER PROVIDED:
{text}
Your task: Find opinions in the answer and rewrite them AS IF YOU ARE THE ONE SAYING THEM.
An opinion is a judgment, viewpoint, or conclusion that goes beyond just stating facts.
IMPORTANT: Do NOT extract statements like:
- "I don't have enough information"
- "The facts don't contain information about X"
- "I cannot answer because..."
ONLY extract actual opinions about substantive topics.
CRITICAL FORMAT REQUIREMENTS:
1. **ALWAYS start with first-person phrases**: "I think...", "I believe...", "In my view...", "I've come to believe...", "Previously I thought... but now..."
2. **NEVER use third-person**: Do NOT say "The speaker thinks..." or "They believe..." - always use "I"
3. Include the reasoning naturally within the statement
4. Provide a confidence score (0.0 to 1.0)
CORRECT Examples (✓ FIRST-PERSON):
- "I think Alice is more reliable because she consistently delivers on time and writes clean code"
- "Previously I thought all engineers were equal, but now I feel that experience and track record really matter"
- "I believe reliability is best measured by consistent output over time"
- "I've come to believe that track records are more important than potential"
WRONG Examples (✗ THIRD-PERSON - DO NOT USE):
- "The speaker thinks Alice is more reliable"
- "They believe reliability matters"
- "It is believed that Alice is better"
If no genuine opinions are expressed (e.g., the response just says "I don't know"), return an empty list."""
try:
result = await llm_config.call(
messages=[
{
"role": "system",
"content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'.",
},
{"role": "user", "content": extraction_prompt},
],
response_format=OpinionExtractionResponse,
scope="memory_extract_opinion",
)
# Format opinions with confidence score and convert to first-person
formatted_opinions = []
for op in result.opinions:
# Convert third-person to first-person if needed
opinion_text = op.opinion
# Replace common third-person patterns with first-person
def singularize_verb(verb):
if verb.endswith("es"):
return verb[:-1] # believes -> believe
elif verb.endswith("s"):
return verb[:-1] # thinks -> think
return verb
# Pattern: "The speaker/user [verb]..." -> "I [verb]..."
match = re.match(
r"^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$",
opinion_text,
re.IGNORECASE,
)
if match:
verb = singularize_verb(match.group(2))
that_part = match.group(3) or "" # Keep " that" if present
rest = match.group(4)
opinion_text = f"I {verb}{that_part}{rest}"
# If still doesn't start with first-person, prepend "I believe that "
first_person_starters = [
"I think",
"I believe",
"I feel",
"In my view",
"I've come to believe",
"Previously I",
]
if not any(opinion_text.startswith(starter) for starter in first_person_starters):
opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:]
formatted_opinions.append(Opinion(opinion=opinion_text, confidence=op.confidence))
return formatted_opinions
except Exception as e:
logger.warning(f"Failed to extract opinions: {str(e)}")
return []
async def reflect(
llm_config,
query: str,
experience_facts: list[str] = None,
world_facts: list[str] = None,
opinion_facts: list[str] = None,
name: str = "Assistant",
disposition: DispositionTraits = None,
background: str = "",
@@ -307,7 +212,6 @@ async def reflect(
query: Question to answer
experience_facts: List of experience/agent fact strings
world_facts: List of world fact strings
opinion_facts: List of opinion fact strings
name: Name of the agent/persona
disposition: Disposition traits (defaults to neutral)
background: Background information
@@ -328,18 +232,15 @@ async def reflect(
agent_results = to_memory_facts(experience_facts or [], "experience")
world_results = to_memory_facts(world_facts or [], "world")
opinion_results = to_memory_facts(opinion_facts or [], "opinion")
# Format facts for prompt
agent_facts_text = format_facts_for_prompt(agent_results)
world_facts_text = format_facts_for_prompt(world_results)
opinion_facts_text = format_facts_for_prompt(opinion_results)
# Build prompt
prompt = build_think_prompt(
agent_facts_text=agent_facts_text,
world_facts_text=world_facts_text,
opinion_facts_text=opinion_facts_text,
query=query,
name=name,
disposition=disposition,
@@ -11,6 +11,13 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
class TemporalConstraint(BaseModel):
"""Detected temporal constraint from query analysis."""
start: datetime | None = Field(default=None, description="Start of temporal range")
end: datetime | None = Field(default=None, description="End of temporal range")
class QueryInfo(BaseModel):
"""Information about the search query."""
@@ -19,6 +26,11 @@ class QueryInfo(BaseModel):
timestamp: datetime = Field(description="When the query was executed")
budget: int = Field(description="Maximum nodes to explore")
max_tokens: int = Field(description="Maximum tokens to return in results")
tags: list[str] | None = Field(default=None, description="Tags filter applied to recall")
tags_match: str | None = Field(default=None, description="Tags matching mode: any, all, any_strict, all_strict")
temporal_constraint: TemporalConstraint | None = Field(
default=None, description="Detected temporal range from query"
)
class EntryPoint(BaseModel):
@@ -73,7 +85,6 @@ 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")
@@ -22,6 +22,7 @@ from .trace import (
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
TemporalConstraint,
WeightComponents,
)
@@ -45,7 +46,14 @@ class SearchTracer:
json_output = trace.to_json()
"""
def __init__(self, query: str, budget: int, max_tokens: int):
def __init__(
self,
query: str,
budget: int,
max_tokens: int,
tags: list[str] | None = None,
tags_match: str | None = None,
):
"""
Initialize tracer.
@@ -53,10 +61,14 @@ class SearchTracer:
query: Search query text
budget: Maximum nodes to explore
max_tokens: Maximum tokens to return in results
tags: Tags filter applied to recall
tags_match: Tags matching mode (any, all, any_strict, all_strict)
"""
self.query_text = query
self.budget = budget
self.max_tokens = max_tokens
self.tags = tags
self.tags_match = tags_match
# Trace data
self.query_embedding: list[float] | None = None
@@ -66,6 +78,9 @@ class SearchTracer:
self.pruned: list[PruningDecision] = []
self.phase_metrics: list[SearchPhaseMetrics] = []
# Temporal constraint detected from query
self.temporal_constraint: TemporalConstraint | None = None
# New 4-way retrieval tracking
self.retrieval_results: list[RetrievalMethodResults] = []
self.rrf_merged: list[RRFMergeResult] = []
@@ -88,6 +103,11 @@ class SearchTracer:
"""Record the query embedding."""
self.query_embedding = embedding
def record_temporal_constraint(self, start: datetime | None, end: datetime | None):
"""Record the detected temporal constraint from query analysis."""
if start is not None or end is not None:
self.temporal_constraint = TemporalConstraint(start=start, end=end)
def add_entry_point(self, node_id: str, text: str, similarity: float, rank: int):
"""
Record an entry point.
@@ -116,7 +136,6 @@ 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,
@@ -135,7 +154,6 @@ 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
@@ -174,7 +192,6 @@ 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,
@@ -313,8 +330,8 @@ class SearchTracer:
RetrievalResult(
rank=rank,
node_id=doc_id,
text=data.get("text", ""),
context=data.get("context", ""),
text=data.get("text") or "",
context=data.get("context") or "",
event_date=data.get("event_date"),
fact_type=data.get("fact_type") or fact_type,
score=score,
@@ -428,6 +445,9 @@ class SearchTracer:
timestamp=datetime.now(UTC),
budget=self.budget,
max_tokens=self.max_tokens,
tags=self.tags,
tags_match=self.tags_match,
temporal_constraint=self.temporal_constraint,
)
# Create summary
@@ -46,8 +46,8 @@ 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
# Retrieval-specific scores (only one will be set depending on retrieval method)
similarity: float | None = None # Semantic retrieval
@@ -70,8 +70,8 @@ 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"),
bm25_score=row.get("bm25_score"),
activation=row.get("activation"),
@@ -154,8 +154,8 @@ 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,
"bm25_score": self.retrieval.bm25_score,
}
+106 -196
View File
@@ -1,31 +1,40 @@
"""
Abstract task backend for running async tasks.
Task backend for distributed task processing.
This provides an abstraction that can be adapted to different execution models:
- AsyncIO queue (default implementation)
- Pub/Sub architectures (future)
- Message brokers (future)
This provides an abstraction for task storage and execution:
- BrokerTaskBackend: Uses PostgreSQL as broker (production)
- SyncTaskBackend: Executes tasks immediately (testing/embedded)
"""
import asyncio
import json
import logging
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import asyncpg
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
2. Execute tasks through a provided executor callback (optional)
The backend treats tasks as pure dictionaries that can be serialized
and sent over the network. The executor (typically MemoryEngine.execute_task)
and stored in the database. The executor (typically MemoryEngine.execute_task)
receives the dict and routes it to the appropriate handler.
"""
@@ -46,7 +55,7 @@ class TaskBackend(ABC):
@abstractmethod
async def initialize(self):
"""
Initialize the backend (e.g., start workers, connect to broker).
Initialize the backend (e.g., connect to database).
"""
pass
@@ -63,7 +72,7 @@ class TaskBackend(ABC):
@abstractmethod
async def shutdown(self):
"""
Shutdown the backend gracefully (e.g., stop workers, close connections).
Shutdown the backend gracefully.
"""
pass
@@ -93,9 +102,8 @@ class SyncTaskBackend(TaskBackend):
"""
Synchronous task backend that executes tasks immediately.
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.
This is useful for tests and embedded/CLI usage where we don't want
background workers. Tasks are executed inline rather than being queued.
"""
async def initialize(self):
@@ -121,221 +129,123 @@ class SyncTaskBackend(TaskBackend):
logger.debug("SyncTaskBackend shutdown")
class NoopTaskBackend(TaskBackend):
class BrokerTaskBackend(TaskBackend):
"""
No-op task backend that discards all tasks.
Task backend using PostgreSQL as broker.
This is useful for tests where background task execution is not needed
and would only slow down the test suite.
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.
"""
async def initialize(self):
"""No-op."""
self._initialized = True
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):
def __init__(
self,
pool_getter: Callable[[], "asyncpg.Pool"],
schema: str | None = None,
):
"""
Initialize AsyncIO queue backend.
Initialize the broker task backend.
Args:
batch_size: Maximum number of tasks to process in one batch
batch_interval: Maximum time (seconds) to wait before processing batch
pool_getter: Callable that returns the asyncpg connection pool
schema: Database schema for multi-tenant support (optional)
"""
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()
self._pool_getter = pool_getter
self._schema = schema
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())
"""Initialize the backend."""
self._initialized = True
logger.info("AsyncIOQueueBackend initialized")
logger.info("BrokerTaskBackend initialized")
async def submit_task(self, task_dict: dict[str, Any]):
"""
Submit a task by putting it in the queue.
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.
Args:
task_dict: Task dictionary to execute
task_dict: Task dictionary to store (must be JSON serializable)
"""
if not self._initialized:
await self.initialize()
await self._queue.put(task_dict)
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")
payload_json = json.dumps(task_dict)
table = fq_table("async_operations", self._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")
async def wait_for_pending_tasks(self, timeout: float = 120.0):
"""
Wait for all pending tasks in the queue and in-flight tasks to complete.
Wait for pending tasks to be processed.
This is useful in tests to ensure background tasks complete before assertions.
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).
Args:
timeout: Maximum time to wait in seconds (default 120s for long-running tasks)
timeout: Maximum time to wait in seconds
"""
if not self._initialized or self._queue is None:
return
import asyncio
pool = self._pool_getter()
table = fq_table("async_operations", self._schema)
# 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:
async with self._in_flight_lock:
in_flight = self._in_flight_count
# 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
"""
)
if self._queue.empty() and in_flight == 0:
# Queue is empty and no tasks in flight, we're done
if count == 0:
return
# Wait a bit before checking again
await asyncio.sleep(0.5)
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
logger.warning(f"Timeout waiting for pending tasks after {timeout}s")
@@ -124,31 +124,6 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
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.
@@ -21,6 +21,10 @@ 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,
# Core operations
OperationValidationError,
OperationValidatorExtension,
RecallContext,
@@ -33,6 +37,7 @@ from hindsight_api.extensions.operation_validator import (
)
from hindsight_api.extensions.tenant import (
AuthenticationError,
Tenant,
TenantContext,
TenantExtension,
)
@@ -47,7 +52,7 @@ __all__ = [
"DefaultExtensionContext",
# HTTP Extension
"HttpExtension",
# Operation Validator
# Operation Validator - Core
"OperationValidationError",
"OperationValidatorExtension",
"RecallContext",
@@ -57,10 +62,14 @@ __all__ = [
"RetainContext",
"RetainResult",
"ValidationResult",
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"AuthenticationError",
"RequestContext",
"Tenant",
"TenantContext",
"TenantExtension",
]
@@ -1,6 +1,6 @@
"""Built-in tenant extension implementations."""
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
@@ -31,3 +31,7 @@ class ApiKeyTenantExtension(TenantExtension):
if context.api_key != self.expected_api_key:
raise AuthenticationError("Invalid API key")
return TenantContext(schema_name="public")
async def list_tenants(self) -> list[Tenant]:
"""Return public schema for single-tenant setup."""
return [Tenant(schema="public")]
@@ -1,4 +1,4 @@
"""Operation Validator Extension for validating retain/recall/reflect operations."""
"""Operation Validator Extension for validating retain/recall/reflect/consolidate operations."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@@ -97,6 +97,19 @@ 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)
# =============================================================================
@@ -164,9 +177,28 @@ 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
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect operations.
Validates and hooks into retain/recall/reflect/consolidate operations.
This extension allows implementing custom logic such as:
- Rate limiting (pre-operation)
@@ -185,9 +217,13 @@ class OperationValidatorExtension(Extension, ABC):
-> config = {"max_requests": "100"}
Hook execution order:
1. validate_retain/validate_recall/validate_reflect (pre-operation)
1. validate_* (pre-operation)
2. [operation executes]
3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation)
3. on_*_complete (post-operation)
Supported operations:
- retain, recall, reflect (core memory operations)
- consolidate (mental models consolidation)
"""
# =========================================================================
@@ -325,3 +361,44 @@ 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
@@ -28,6 +28,18 @@ 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.
@@ -61,3 +73,17 @@ 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")].
"""
...
+19 -3
View File
@@ -184,18 +184,26 @@ def main():
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,
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,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
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_tei_url=config.reranker_tei_url,
reranker_tei_batch_size=config.reranker_tei_batch_size,
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
reranker_max_candidates=config.reranker_max_candidates,
reranker_cohere_base_url=config.reranker_cohere_base_url,
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,
@@ -208,6 +216,9 @@ def main():
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_observations_async=config.retain_observations_async,
enable_mental_models=config.enable_mental_models,
consolidation_similarity_threshold=config.consolidation_similarity_threshold,
consolidation_batch_size=config.consolidation_batch_size,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
run_migrations_on_startup=config.run_migrations_on_startup,
@@ -215,9 +226,14 @@ 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,
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,
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_batch_size=config.worker_batch_size,
worker_http_port=config.worker_http_port,
reflect_max_iterations=config.reflect_max_iterations,
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
)
config.configure_logging()
if not args.daemon:
+11 -52
View File
@@ -44,7 +44,6 @@ 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,
@@ -53,6 +52,7 @@ 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,9 +85,6 @@ 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:
@@ -105,55 +102,17 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
mcp = FastMCP("hindsight")
@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
# 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
)
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": []}
register_mcp_tools(mcp, memory, config)
return mcp
+494
View File
@@ -0,0 +1,494 @@
"""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]
# 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 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}
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=RequestContext(),
)
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]
if async_processing:
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:
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)}"
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}
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=RequestContext(),
)
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=RequestContext(),
)
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=RequestContext(),
)
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=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": ""}}'
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=RequestContext(),
)
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=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": []}}'
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:
# 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}"}}'
+15
View File
@@ -28,6 +28,15 @@ from opentelemetry.sdk.resources import Resource
if TYPE_CHECKING:
import asyncpg
def _get_tenant() -> str:
"""Get current tenant (schema) from context for metrics labeling."""
# Import here to avoid circular imports
from hindsight_api.engine.memory_engine import get_current_schema
return get_current_schema()
# Custom bucket boundaries for operation duration (in seconds)
# Fine granularity in 0-30s range where most operations complete
DURATION_BUCKETS = (0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0)
@@ -323,6 +332,7 @@ class MetricsCollector(MetricsCollectorBase):
"operation": operation,
"bank_id": bank_id,
"source": source,
"tenant": _get_tenant(),
}
if budget:
attributes["budget"] = budget
@@ -373,6 +383,7 @@ class MetricsCollector(MetricsCollectorBase):
"model": model,
"scope": scope,
"success": str(success).lower(),
"tenant": _get_tenant(),
}
# Record duration
@@ -425,10 +436,14 @@ class MetricsCollector(MetricsCollectorBase):
status_code = status_code_getter()
status_class = f"{status_code // 100}xx"
# Get tenant from context (may be set during request processing)
tenant = _get_tenant()
attributes = {
**base_attributes,
"status_code": str(status_code),
"status_class": status_class,
"tenant": tenant,
}
# Record duration and count
-2
View File
@@ -95,7 +95,6 @@ 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)
@@ -131,7 +130,6 @@ 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(
+31 -1
View File
@@ -7,6 +7,7 @@ This module provides the ASGI app for uvicorn import string usage:
For CLI usage, use the hindsight-api command instead.
"""
import logging
import os
import warnings
@@ -17,6 +18,12 @@ warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProt
from hindsight_api import MemoryEngine
from hindsight_api.api import create_app
from hindsight_api.config import get_config
from hindsight_api.extensions import (
DefaultExtensionContext,
OperationValidatorExtension,
TenantExtension,
load_extension,
)
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
@@ -25,10 +32,33 @@ os.environ["TOKENIZERS_PARALLELISM"] = "false"
config = get_config()
config.configure_logging()
# Load operation validator extension if configured
operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
if operation_validator:
logging.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")
# Load tenant extension if configured
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension:
logging.info(f"Loaded tenant extension: {tenant_extension.__class__.__name__}")
# Create app at module level (required for uvicorn import string)
# MemoryEngine reads configuration from environment variables automatically
# Note: run_migrations=True by default, but migrations are idempotent so safe with workers
_memory = MemoryEngine(run_migrations=config.run_migrations_on_startup)
_memory = MemoryEngine(
operation_validator=operation_validator,
tenant_extension=tenant_extension,
run_migrations=config.run_migrations_on_startup,
)
# Set extension context on tenant extension (needed for schema provisioning)
if tenant_extension:
extension_context = DefaultExtensionContext(
database_url=config.database_url,
memory_engine=_memory,
)
tenant_extension.set_context(extension_context)
logging.info("Extension context set on tenant extension")
# Create unified app with both HTTP and optionally MCP
app = create_app(
@@ -0,0 +1,11 @@
"""
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"]
+296
View File
@@ -0,0 +1,296 @@
"""
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(
"--batch-size",
type=int,
default=config.worker_batch_size,
help=f"Tasks to claim per poll (default: {config.worker_batch_size}, env: HINDSIGHT_API_WORKER_BATCH_SIZE)",
)
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" Batch size: {args.batch_size}")
print(f" Max retries: {args.max_retries}")
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 TenantExtension, load_extension
# 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(),
)
await memory.initialize()
print(f"Database connected: {config.database_url}")
# Load tenant extension for dynamic schema discovery
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension:
print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
else:
print("No tenant extension configured, using public schema only")
# Create a single poller that handles all schemas dynamically
poller = WorkerPoller(
pool=memory._pool,
worker_id=args.worker_id,
executor=memory.execute_task,
poll_interval_ms=args.poll_interval,
batch_size=args.batch_size,
max_retries=args.max_retries,
tenant_extension=tenant_extension,
)
# Create the HTTP app for metrics/health
app = create_worker_app(poller, memory)
# Setup signal handlers for graceful shutdown
shutdown_requested = asyncio.Event()
def signal_handler(signum, frame):
print(f"\nReceived signal {signum}, initiating graceful shutdown...")
shutdown_requested.set()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(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
await shutdown_requested.wait()
# 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()
@@ -0,0 +1,483 @@
"""
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,
batch_size: int = 10,
max_retries: int = 3,
schema: str | None = None,
tenant_extension: "TenantExtension | None" = None,
):
"""
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)
batch_size: Maximum number of tasks to claim per poll cycle
max_retries: Maximum retry attempts before marking task as failed
schema: Database schema for single-tenant support (ignored if tenant_extension is set)
tenant_extension: Extension for dynamic multi-tenant discovery. If set, list_tenants()
is called on each poll cycle to discover schemas dynamically.
"""
self._pool = pool
self._worker_id = worker_id
self._executor = executor
self._poll_interval_ms = poll_interval_ms
self._batch_size = batch_size
self._max_retries = max_retries
self._schema = schema
self._tenant_extension = tenant_extension
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)
self._active_tasks: dict[str, tuple[str, str, str | None]] = {}
async def _get_schemas(self) -> list[str | None]:
"""Get list of schemas to poll. Returns [None] for public schema."""
if self._tenant_extension is not None:
tenants = await self._tenant_extension.list_tenants()
# Convert "public" to None for SQL compatibility, keep others as-is
return [t.schema if t.schema != "public" else None for t in tenants]
# Single schema mode
return [self._schema]
async def claim_batch(self) -> list[ClaimedTask]:
"""
Claim up to batch_size pending tasks atomically across all tenant schemas.
Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers.
For consolidation tasks specifically, skips pending tasks if there's already
a processing consolidation for the same bank (to avoid duplicate work).
If tenant_extension is configured, dynamically discovers schemas on each call.
Returns:
List of ClaimedTask objects containing operation_id, task_dict, and schema
"""
schemas = await self._get_schemas()
all_tasks: list[ClaimedTask] = []
remaining_batch = self._batch_size
for schema in schemas:
if remaining_batch <= 0:
break
tasks = await self._claim_batch_for_schema(schema, remaining_batch)
all_tasks.extend(tasks)
remaining_batch -= len(tasks)
return all_tasks
async def _claim_batch_for_schema(self, schema: str | None, limit: int) -> list[ClaimedTask]:
"""Claim tasks from a specific schema."""
table = fq_table("async_operations", schema)
async with self._pool.acquire() as conn:
async with conn.transaction():
# Select and lock pending tasks
# For consolidation: skip if same bank already has one processing
rows = await conn.fetch(
f"""
SELECT operation_id, task_payload
FROM {table} AS pending
WHERE status = 'pending' AND task_payload IS NOT NULL
AND (
-- Non-consolidation tasks: always claimable
operation_type != 'consolidation'
OR
-- Consolidation: only if no other consolidation processing for same bank
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
""",
limit,
)
if not rows:
return []
# Claim the tasks by updating status and worker_id
operation_ids = [row["operation_id"] for row in 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 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 and update its status."""
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
# Track this task as active
async with self._in_flight_lock:
self._active_tasks[task.operation_id] = (task_type, bank_id, task.schema)
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})")
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)
finally:
# Remove from active tasks
async with self._in_flight_lock:
self._active_tasks.pop(task.operation_id, None)
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:
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
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.
Continuously polls for pending tasks, claims them, and executes them
until shutdown is signaled.
If tenant_extension is configured, dynamically discovers schemas on each poll.
"""
# Recover any tasks from a previous crash before starting
await self.recover_own_tasks()
logger.info(f"Worker {self._worker_id} starting polling loop")
while not self._shutdown.is_set():
try:
# Claim a batch of tasks (across all tenant schemas if configured)
tasks = await self.claim_batch()
if tasks:
# Log batch info
task_types: dict[str, int] = {}
schemas_seen: set[str | None] = set()
for task in tasks:
t = task.task_dict.get("type", "unknown")
task_types[t] = task_types.get(t, 0) + 1
schemas_seen.add(task.schema)
types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
schemas_str = ", ".join(s or "public" for s in schemas_seen)
logger.info(
f"Worker {self._worker_id} claimed {len(tasks)} tasks: {types_str} (schemas: {schemas_str})"
)
# Track in-flight tasks
async with self._in_flight_lock:
self._in_flight_count += len(tasks)
# Execute tasks concurrently
try:
await asyncio.gather(
*[self.execute_task(task) for task in tasks],
return_exceptions=True,
)
finally:
async with self._in_flight_lock:
self._in_flight_count -= len(tasks)
else:
# No tasks found, 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
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")
await asyncio.sleep(0.5)
logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s")
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 (this worker only)
async with self._in_flight_lock:
in_flight = self._in_flight_count
active_tasks = dict(self._active_tasks) # Copy to avoid holding lock
# Build local processing breakdown grouped by (op_type, bank_id)
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 across all schemas
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
# Get processing breakdown by worker
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"]
# Format other workers' processing counts
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"
schemas_str = ", ".join(s or "public" for s in schemas)
logger.info(
f"[WORKER_STATS] worker={self._worker_id} in_flight={in_flight} | "
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()
+15 -7
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.2.1"
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.3.0",
"fastmcp>=2.14.0", # CVE-2025-66416
"pg0-embedded>=0.11.0",
"python-dateutil>=2.8.0",
"opentelemetry-api>=1.20.0",
@@ -39,10 +39,17 @@ dependencies = [
"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.0.0,<3.3.0",
"transformers>=4.30.0,<4.46.0",
"torch>=2.0.0",
"sentence-transformers>=3.3.0",
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
"torch>=2.6.0", # CVE fix for remote code execution
"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
]
[project.optional-dependencies]
@@ -51,11 +58,12 @@ test = [
"pytest-asyncio>=0.21.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.0.0",
"filelock>=3.0.0",
"filelock>=3.20.1", # TOCTOU race condition fix
]
[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"
@@ -97,7 +105,7 @@ dev = [
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"python-dotenv>=1.2.1",
"filelock>=3.0.0",
"filelock>=3.20.1", # TOCTOU race condition fix
"ruff>=0.8.0",
"ty>=0.0.1",
]
+56 -4
View File
@@ -12,6 +12,7 @@ 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
@@ -115,16 +116,65 @@ def llm_config():
@pytest.fixture(scope="session")
def embeddings():
def embeddings(tmp_path_factory, worker_id):
"""
Session-scoped embeddings fixture with filelock to prevent race conditions.
return LocalSTEmbeddings()
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 / "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():
def cross_encoder(tmp_path_factory, worker_id):
"""
Session-scoped cross-encoder fixture with filelock to prevent race conditions.
return LocalSTCrossEncoder()
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
@pytest.fixture(scope="session")
def query_analyzer():
@@ -147,6 +197,7 @@ 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://
@@ -160,6 +211,7 @@ 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
+1 -110
View File
@@ -1,5 +1,5 @@
"""
Tests for agent management API (profile, disposition, background).
Tests for agent management API (profile, disposition).
"""
import pytest
import uuid
@@ -25,15 +25,12 @@ class TestAgentProfile:
assert profile is not None
assert "disposition" in profile
assert "background" in profile
disposition = profile["disposition"]
assert disposition.skepticism == 3
assert disposition.literalism == 3
assert disposition.empathy == 3
assert profile["background"] == ""
@pytest.mark.asyncio
async def test_update_agent_disposition(self, memory: MemoryEngine, request_context):
"""Test updating agent disposition traits."""
@@ -76,63 +73,10 @@ class TestAgentProfile:
for agent in agents:
assert "bank_id" in agent
assert "disposition" in agent
assert "background" in agent
assert "created_at" in agent
assert "updated_at" in agent
class TestAgentBackground:
"""Tests for agent background management."""
@pytest.mark.asyncio
async def test_merge_agent_background(self, memory: MemoryEngine, request_context):
"""Test merging agent background information."""
bank_id = unique_agent_id("test_profile_merge")
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert profile["background"] == ""
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Texas",
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"I have 10 years of startup experience",
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result2["background"] or "startup" in result2["background"]
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["background"] != ""
@pytest.mark.asyncio
async def test_merge_background_handles_conflicts(self, memory: MemoryEngine, request_context):
"""Test that merging background handles conflicts (new overwrites old)."""
bank_id = unique_agent_id("test_profile_conflict")
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Colorado",
update_disposition=False,
request_context=request_context,
)
assert "Colorado" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"You were born in Texas",
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result2["background"]
class TestAgentEndpoint:
"""Tests for agent PUT endpoint logic."""
@@ -147,7 +91,6 @@ class TestAgentEndpoint:
literalism=5,
empathy=2
),
background="I am a creative software engineer"
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -159,55 +102,10 @@ class TestAgentEndpoint:
request_context=request_context,
)
if request.background is not None:
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
request.background
)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["disposition"].skepticism == 4
assert final_profile["disposition"].literalism == 5
assert final_profile["background"] == "I am a creative software engineer"
@pytest.mark.asyncio
async def test_put_agent_partial_update(self, memory: MemoryEngine, request_context):
"""Test updating only background."""
bank_id = unique_agent_id("test_put_partial")
request = CreateBankRequest(
background="I am a data scientist"
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
if request.background is not None:
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
request.background
)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["disposition"].skepticism == 3 # Default
assert final_profile["background"] == "I am a data scientist"
class TestAgentDispositionIntegration:
@@ -225,13 +123,6 @@ class TestAgentDispositionIntegration:
}
await memory.update_bank_disposition(bank_id, disposition, request_context=request_context)
await memory.merge_bank_background(
bank_id,
"I am a creative artist who values innovation over tradition",
update_disposition=False,
request_context=request_context,
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
File diff suppressed because it is too large Load Diff
@@ -9,17 +9,18 @@ Includes tests for:
import asyncio
import os
import pytest
from datetime import datetime
import pytest
from sqlalchemy import create_engine, text
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api.engine.embeddings import LocalSTEmbeddings, OpenAIEmbeddings, CohereEmbeddings
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder, CohereCrossEncoder
from hindsight_api.engine.cross_encoder import CohereCrossEncoder, LocalSTCrossEncoder
from hindsight_api.engine.embeddings import CohereEmbeddings, LocalSTEmbeddings, OpenAIEmbeddings
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.extensions import TenantExtension, TenantContext
from hindsight_api.migrations import run_migrations, ensure_embedding_dimension
from hindsight_api.engine.task_backend import SyncTaskBackend
from hindsight_api.extensions import TenantContext, TenantExtension
from hindsight_api.migrations import ensure_embedding_dimension, run_migrations
# =============================================================================
# Shared Utilities
@@ -35,6 +36,11 @@ class SchemaTenantExtension(TenantExtension):
async def authenticate(self, request_context: RequestContext) -> TenantContext:
return TenantContext(schema_name=self.schema_name)
async def list_tenants(self) -> list:
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema=self.schema_name)]
def get_test_schema(prefix: str, worker_id: str) -> str:
"""Get unique schema name per xdist worker."""
@@ -323,6 +329,7 @@ class TestOpenAIEmbeddings:
pool_max_size=3,
run_migrations=False,
tenant_extension=SchemaTenantExtension(schema_name),
task_backend=SyncTaskBackend(),
)
try:
@@ -392,6 +399,7 @@ class TestOpenAIEmbeddings:
pool_max_size=3,
run_migrations=False,
tenant_extension=SchemaTenantExtension(schema_name),
task_backend=SyncTaskBackend(),
)
try:
@@ -559,6 +567,7 @@ class TestCohereIntegration:
pool_max_size=3,
run_migrations=False,
tenant_extension=SchemaTenantExtension(schema_name),
task_backend=SyncTaskBackend(),
)
try:
+17 -2
View File
@@ -24,6 +24,9 @@ from hindsight_api.extensions import (
TenantExtension,
ValidationResult,
load_extension,
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
)
@@ -128,14 +131,18 @@ class TrackingValidator(OperationValidatorExtension):
def __init__(self, config: dict):
super().__init__(config)
# Pre-hook tracking
# Pre-hook tracking - Core operations
self.pre_retain_calls: list[RetainContext] = []
self.pre_recall_calls: list[RecallContext] = []
self.pre_reflect_calls: list[ReflectContext] = []
# Post-hook tracking
# Post-hook tracking - Core operations
self.post_retain_calls: list[RetainResult] = []
self.post_recall_calls: list[RecallResult] = []
self.post_reflect_calls: list[ReflectResultContext] = []
# Pre-hook tracking - Consolidation
self.pre_consolidate_calls: list[ConsolidateContext] = []
# Post-hook tracking - Consolidation
self.post_consolidate_calls: list[ConsolidateResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.pre_retain_calls.append(ctx)
@@ -158,6 +165,14 @@ class TrackingValidator(OperationValidatorExtension):
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
self.post_reflect_calls.append(result)
# Consolidation hooks
async def validate_consolidate(self, ctx: ConsolidateContext) -> ValidationResult:
self.pre_consolidate_calls.append(ctx)
return ValidationResult.accept()
async def on_consolidate_complete(self, result: ConsolidateResult) -> None:
self.post_consolidate_calls.append(result)
class TestMemoryEngineValidation:
"""Tests for validation integration with MemoryEngine.
@@ -947,172 +947,3 @@ so the algorithm learns to box out. See you next week!
raise e
# =============================================================================
# DISPOSITION INFERENCE TESTS
# =============================================================================
class TestDispositionInference:
"""Tests for LLM-based disposition trait inference from background."""
@pytest.mark.asyncio
async def test_background_merge_with_disposition_inference(self, memory, request_context):
"""Test that background merge infers disposition traits by default."""
import uuid
bank_id = f"test_infer_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a creative software engineer who loves innovation and trying new technologies",
update_disposition=True,
request_context=request_context,
)
assert "background" in result
assert "disposition" in result
background = result["background"]
disposition = result["disposition"]
assert "creative" in background.lower() or "innovation" in background.lower()
# Check that new traits are present with valid values (1-5)
required_traits = ["skepticism", "literalism", "empathy"]
for trait in required_traits:
assert trait in disposition
assert 1 <= disposition[trait] <= 5
@pytest.mark.asyncio
async def test_background_merge_without_disposition_inference(self, memory, request_context):
"""Test that background merge skips disposition inference when disabled."""
import uuid
bank_id = f"test_no_infer_{uuid.uuid4().hex[:8]}"
initial_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
initial_disposition = initial_profile["disposition"]
result = await memory.merge_bank_background(
bank_id,
"I am a data scientist",
update_disposition=False,
request_context=request_context,
)
assert "background" in result
assert "disposition" not in result
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
final_disposition = final_profile["disposition"]
assert initial_disposition == final_disposition
@pytest.mark.asyncio
async def test_disposition_inference_for_lawyer(self, memory, request_context):
"""Test disposition inference for lawyer profile (high skepticism, high literalism)."""
import uuid
bank_id = f"test_lawyer_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a lawyer who focuses on contract details and never takes claims at face value",
update_disposition=True,
request_context=request_context,
)
disposition = result["disposition"]
# Lawyers should have higher skepticism and literalism
assert disposition["skepticism"] >= 3
assert disposition["literalism"] >= 3
@pytest.mark.asyncio
async def test_disposition_inference_for_therapist(self, memory, request_context):
"""Test disposition inference for therapist profile (high empathy)."""
import uuid
bank_id = f"test_therapist_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a therapist who deeply understands and connects with people's emotional struggles",
update_disposition=True,
request_context=request_context,
)
disposition = result["disposition"]
# Therapists should have higher empathy
assert disposition["empathy"] >= 3
@pytest.mark.asyncio
async def test_disposition_updates_in_database(self, memory, request_context):
"""Test that inferred disposition is actually stored in database."""
import uuid
bank_id = f"test_db_update_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am an innovative designer",
update_disposition=True,
request_context=request_context,
)
inferred_disposition = result["disposition"]
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
db_disposition = profile["disposition"]
# Compare values (db_disposition is a Pydantic model)
assert db_disposition.skepticism == inferred_disposition["skepticism"]
assert db_disposition.literalism == inferred_disposition["literalism"]
assert db_disposition.empathy == inferred_disposition["empathy"]
@pytest.mark.asyncio
async def test_multiple_background_merges_update_disposition(self, memory, request_context):
"""Test that each background merge can update disposition."""
import uuid
bank_id = f"test_multi_merge_{uuid.uuid4().hex[:8]}"
result1 = await memory.merge_bank_background(
bank_id,
"I am a software engineer",
update_disposition=True,
request_context=request_context,
)
disposition1 = result1["disposition"]
result2 = await memory.merge_bank_background(
bank_id,
"I love creative problem solving and innovation",
update_disposition=True,
request_context=request_context,
)
disposition2 = result2["disposition"]
assert "engineer" in result2["background"].lower() or "software" in result2["background"].lower()
assert "creative" in result2["background"].lower() or "innovation" in result2["background"].lower()
@pytest.mark.asyncio
async def test_background_merge_conflict_resolution_with_disposition(self, memory, request_context):
"""Test that conflicts are resolved and disposition reflects final background."""
import uuid
bank_id = f"test_conflict_{uuid.uuid4().hex[:8]}"
await memory.merge_bank_background(
bank_id,
"I was born in Colorado and prefer stability",
update_disposition=True,
request_context=request_context,
)
result = await memory.merge_bank_background(
bank_id,
"You were born in Texas and are very skeptical of people",
update_disposition=True,
request_context=request_context,
)
background = result["background"]
disposition = result["disposition"]
assert "texas" in background.lower()
# Higher skepticism expected from "very skeptical of people"
assert disposition["skepticism"] >= 3
+27 -28
View File
@@ -51,7 +51,7 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
results = await memory.recall_async(
bank_id=bank_id,
query="Marcus prediction Rams",
fact_type=['opinion', 'experience', 'world'],
fact_type=['experience', 'world'],
budget=Budget.LOW,
max_tokens=8192,
request_context=request_context,
@@ -61,8 +61,8 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
for i, result in enumerate(results.results):
print(f"{i+1}. [{result.mentioned_at}] {result.text[:100]}")
# Get all opinion facts (Marcus's predictions/statements)
agent_facts = [r for r in results.results if r.fact_type == 'opinion']
# Get all facts (Marcus's predictions/statements)
agent_facts = results.results
print(f"\n=== Agent facts (Marcus's statements) ===")
for i, fact in enumerate(agent_facts):
@@ -70,6 +70,7 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
# Check that agent facts have different timestamps
if len(agent_facts) >= 2:
# Parse timestamps
timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in agent_facts]
# Verify timestamps are different (have time offsets)
@@ -77,42 +78,40 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
assert len(unique_timestamps) == len(timestamps), \
f"Expected unique timestamps for each fact, but got duplicates: {timestamps}"
# Verify timestamps are in order (ascending)
for i in range(len(timestamps) - 1):
assert timestamps[i] < timestamps[i + 1], \
f"Facts should be ordered by time. Fact {i} ({timestamps[i]}) >= Fact {i+1} ({timestamps[i+1]})"
# Sort facts by timestamp for ordering check
# Note: recall returns by relevance, not time order
sorted_facts = sorted(agent_facts, key=lambda f: datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')))
sorted_timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in sorted_facts]
# Verify sorted timestamps are in ascending order
for i in range(len(sorted_timestamps) - 1):
assert sorted_timestamps[i] < sorted_timestamps[i + 1], \
f"Facts should have sequential timestamps. Fact {i} ({sorted_timestamps[i]}) >= Fact {i+1} ({sorted_timestamps[i+1]})"
# Verify reasonable time spacing (should be ~10 seconds apart)
time_diffs = [(timestamps[i+1] - timestamps[i]).total_seconds() for i in range(len(timestamps) - 1)]
time_diffs = [(sorted_timestamps[i+1] - sorted_timestamps[i]).total_seconds() for i in range(len(sorted_timestamps) - 1)]
print(f"\n=== Time differences between facts: {time_diffs} seconds ===")
# Each fact should be 10+ seconds apart (allowing for some flexibility)
for diff in time_diffs:
assert diff >= 5, f"Expected at least 5 seconds between facts, got {diff}"
# Update agent_facts to be sorted for subsequent checks
agent_facts = sorted_facts
timestamps = sorted_timestamps
print(f"\n✅ All {len(agent_facts)} agent facts have properly ordered timestamps")
# Verify that retrieval returns facts in chronological order
# The first prediction should come before the changed prediction
# Verify that facts capture the key information
# Note: LLM may merge related predictions into single facts
agent_texts = [f.text.lower() for f in agent_facts]
all_text = " ".join(agent_texts)
# Look for evidence of the sequence
has_first_prediction = any('27' in text and '24' in text for text in agent_texts)
has_changed_prediction = any('chang' in text or 'by 3' in text or 'realized' in text for text in agent_texts)
# Look for evidence of the predictions being captured (may be merged or separate)
has_prediction_info = '27' in all_text or 'rams' in all_text or 'prediction' in all_text
if has_first_prediction and has_changed_prediction:
# Find indices
first_idx = next(i for i, text in enumerate(agent_texts) if '27' in text and '24' in text)
changed_idx = next(i for i, text in enumerate(agent_texts) if 'chang' in text or 'by 3' in text or 'realized' in text)
print(f"\nFirst prediction at index {first_idx}: {agent_facts[first_idx].text[:100]}")
print(f"Changed prediction at index {changed_idx}: {agent_facts[changed_idx].text[:100]}")
# The original prediction should come before the changed one
assert timestamps[first_idx] < timestamps[changed_idx], \
"Original prediction should have earlier timestamp than changed prediction"
print(f"\n✅ Temporal ordering preserved: First prediction came before changed prediction")
assert has_prediction_info, "Facts should contain information about Marcus's predictions"
print(f"\n✅ Facts capture prediction information")
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -156,14 +155,14 @@ Alice: I reconsidered the team's experience level.
results = await memory.recall_async(
bank_id=bank_id,
query="Alice preference React Vue",
fact_type=['opinion', 'experience'],
fact_type=['experience', 'world'],
budget=Budget.LOW,
max_tokens=8192,
request_context=request_context,
)
print(f"\n=== Retrieved {len(results.results)} agent facts ===")
agent_facts = [r for r in results.results if r.fact_type in ('opinion', 'experience')]
agent_facts = results.results
for i, fact in enumerate(agent_facts):
print(f"{i+1}. [{fact.mentioned_at}] {fact.text[:80]}")
@@ -60,17 +60,6 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert response.status_code == 200
profile = response.json()
assert "disposition" in profile
assert "background" in profile
# Add background
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/background",
json={
"content": "A software engineer passionate about AI and memory systems."
}
)
assert response.status_code == 200
assert "software engineer" in response.json()["background"].lower()
# ================================================================
# 2. Memory Storage
@@ -244,7 +233,9 @@ async def test_full_api_workflow(api_client, test_bank_id):
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200
updated_profile = response.json()
assert "software engineer" in updated_profile["background"].lower()
assert updated_profile["disposition"]["skepticism"] == 4
assert updated_profile["disposition"]["literalism"] == 3
assert updated_profile["disposition"]["empathy"] == 4
# ================================================================
# 8. Test Entity Endpoints
@@ -289,11 +280,11 @@ async def test_full_api_workflow(api_client, test_bank_id):
entity_detail = response.json()
assert "id" in entity_detail
# Test regenerate observations
# Test regenerate observations (deprecated - returns 410 Gone)
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/entities/{entity_id}/regenerate"
)
assert response.status_code == 200
assert response.status_code == 410 # Deprecated endpoint
# ================================================================
# 9. List All Banks (should include our test bank)
@@ -845,9 +836,8 @@ async def test_reflect_structured_output(api_client):
assert response.status_code == 200
result = response.json()
# Verify text field exists (empty when using structured output)
# Verify text field exists (may contain text even with structured output)
assert "text" in result
assert result["text"] == ""
# Verify structured output exists and has expected structure
assert "structured_output" in result
@@ -979,10 +969,12 @@ async def test_reflect_returns_token_usage(api_client):
assert "text" in result
assert len(result["text"]) > 0
# Verify usage field exists and has expected structure
# Verify usage field exists and is populated (agentic reflect aggregates all LLM calls)
assert "usage" in result, "Response should include 'usage' field"
usage = result["usage"]
assert usage is not None, "Usage should not be None for reflect"
# Usage must be present - agentic reflect now aggregates token usage from all LLM calls
assert usage is not None, "Usage should not be None - reflect aggregates all LLM call usages"
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
+329
View File
@@ -0,0 +1,329 @@
"""
Tests for LLM tool calling functionality.
"""
import pytest
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult
# Sample tools for testing
SAMPLE_TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
},
},
{
"type": "function",
"function": {
"name": "search",
"description": "Search for information",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
},
"required": ["query"],
},
},
},
]
class TestMockToolCalling:
"""Test tool calling with mock provider."""
@pytest.mark.asyncio
async def test_call_with_tools_returns_tool_calls(self):
"""Test that mock provider can return tool calls."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
# Set mock response to return tool calls
llm.set_mock_response([
{"name": "get_weather", "arguments": {"location": "Paris", "unit": "celsius"}},
])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
tools=SAMPLE_TOOLS,
)
assert isinstance(result, LLMToolCallResult)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[0].arguments == {"location": "Paris", "unit": "celsius"}
assert result.finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_call_with_tools_returns_content(self):
"""Test that mock provider can return plain content."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
# Default mock response is plain content
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Hello"}],
tools=SAMPLE_TOOLS,
)
assert isinstance(result, LLMToolCallResult)
assert result.content == "mock response"
assert len(result.tool_calls) == 0
assert result.finish_reason == "stop"
@pytest.mark.asyncio
async def test_call_with_tools_records_calls(self):
"""Test that mock calls are recorded."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
llm.clear_mock_calls()
await llm.call_with_tools(
messages=[{"role": "user", "content": "Test message"}],
tools=SAMPLE_TOOLS,
scope="test_scope",
)
calls = llm.get_mock_calls()
assert len(calls) == 1
assert calls[0]["scope"] == "test_scope"
assert "get_weather" in calls[0]["tools"]
assert "search" in calls[0]["tools"]
@pytest.mark.asyncio
async def test_call_with_tools_multiple_tool_calls(self):
"""Test handling multiple tool calls in one response."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
llm.set_mock_response([
{"name": "get_weather", "arguments": {"location": "Paris"}},
{"name": "search", "arguments": {"query": "weather forecast"}},
])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Weather in Paris and search for forecasts"}],
tools=SAMPLE_TOOLS,
)
assert len(result.tool_calls) == 2
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[1].name == "search"
@pytest.mark.asyncio
async def test_call_with_tools_accepts_llm_tool_call_result(self):
"""Test that mock can accept LLMToolCallResult directly."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
expected_result = LLMToolCallResult(
content="Here's the info",
tool_calls=[LLMToolCall(id="call_123", name="search", arguments={"query": "test"})],
finish_reason="tool_calls",
)
llm.set_mock_response(expected_result)
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Search for test"}],
tools=SAMPLE_TOOLS,
)
assert result == expected_result
class TestToolCallConversation:
"""Test tool call conversation flow."""
@pytest.mark.asyncio
async def test_tool_result_message_format(self):
"""Test that tool result messages can be passed in subsequent calls."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
# First call returns tool call
llm.set_mock_response([{"name": "get_weather", "arguments": {"location": "Paris"}}])
result1 = await llm.call_with_tools(
messages=[{"role": "user", "content": "What's the weather?"}],
tools=SAMPLE_TOOLS,
)
# Build conversation with tool result
messages = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"tool_calls": [
{
"id": result1.tool_calls[0].id,
"type": "function",
"function": {
"name": result1.tool_calls[0].name,
"arguments": '{"location": "Paris"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": result1.tool_calls[0].id,
"content": '{"temperature": 20, "conditions": "sunny"}',
},
]
# Second call should work with tool result in history
llm.set_mock_response(None) # Reset to default
result2 = await llm.call_with_tools(
messages=messages,
tools=SAMPLE_TOOLS,
)
assert result2.content == "mock response"
class TestToolSchemas:
"""Test tool schema handling."""
@pytest.mark.asyncio
async def test_empty_tools_list(self):
"""Test calling with empty tools list."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "Hello"}],
tools=[],
)
assert result.content == "mock response"
@pytest.mark.asyncio
async def test_tool_with_no_required_params(self):
"""Test tool with no required parameters."""
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
tools = [
{
"type": "function",
"function": {
"name": "list_items",
"description": "List all items",
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
]
llm.set_mock_response([{"name": "list_items", "arguments": {}}])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "List items"}],
tools=tools,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "list_items"
assert result.tool_calls[0].arguments == {}
class TestReflectToolSchemas:
"""Test reflect agent tool schemas."""
def test_get_reflect_tools_default(self):
"""Test getting default reflect tools."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools()
tool_names = [t["function"]["name"] for t in tools]
assert "search_reflections" in tool_names
assert "search_mental_models" in tool_names
assert "recall" in tool_names
assert "expand" in tool_names
assert "done" in tool_names
def test_get_reflect_tools_with_directives(self):
"""Test getting reflect tools with directive rules."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools(directive_rules=["Always respond in French"])
tool_names = [t["function"]["name"] for t in tools]
assert "recall" in tool_names
assert "done" in tool_names
# Done tool should have directive_compliance field when directives are present
done_tool = next(t for t in tools if t["function"]["name"] == "done")
params = done_tool["function"]["parameters"]["properties"]
assert "directive_compliance" in params
def test_get_reflect_tools_answer_mode(self):
"""Test getting reflect tools with answer output mode."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools()
done_tool = next(t for t in tools if t["function"]["name"] == "done")
params = done_tool["function"]["parameters"]["properties"]
assert "answer" in params
assert "memory_ids" in params
assert "mental_model_ids" in params
assert "reflection_ids" in params
class TestLLMToolCallResult:
"""Test LLMToolCallResult model."""
def test_tool_call_result_defaults(self):
"""Test default values for LLMToolCallResult."""
result = LLMToolCallResult()
assert result.content is None
assert result.tool_calls == []
assert result.finish_reason is None
def test_tool_call_result_with_content(self):
"""Test LLMToolCallResult with content."""
result = LLMToolCallResult(content="Hello", finish_reason="stop")
assert result.content == "Hello"
assert result.tool_calls == []
assert result.finish_reason == "stop"
def test_tool_call_result_with_tool_calls(self):
"""Test LLMToolCallResult with tool calls."""
result = LLMToolCallResult(
tool_calls=[
LLMToolCall(id="call_1", name="test_tool", arguments={"arg": "value"}),
],
finish_reason="tool_calls",
)
assert result.content is None
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "test_tool"
assert result.finish_reason == "tool_calls"
class TestLLMToolCall:
"""Test LLMToolCall model."""
def test_tool_call_basic(self):
"""Test basic LLMToolCall creation."""
call = LLMToolCall(id="call_123", name="get_weather", arguments={"location": "Paris"})
assert call.id == "call_123"
assert call.name == "get_weather"
assert call.arguments == {"location": "Paris"}
def test_tool_call_empty_arguments(self):
"""Test LLMToolCall with empty arguments."""
call = LLMToolCall(id="call_456", name="list_items", arguments={})
assert call.arguments == {}
@@ -19,6 +19,7 @@ import pytest_asyncio
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings, RequestContext
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.engine.retain.fact_extraction import FactExtractionResponse, ExtractedFact
from hindsight_api.engine.llm_wrapper import TokenUsage
@@ -106,6 +107,7 @@ class TestLargeBatchRetain:
pool_max_size=10,
run_migrations=False,
skip_llm_verification=True, # Skip LLM verification since we're mocking
task_backend=SyncTaskBackend(), # Execute tasks immediately in tests
)
await mem.initialize()
yield mem
+401
View File
@@ -0,0 +1,401 @@
"""
Tests for hindsight_api.main module (single-worker code path).
The main.py module is used when running with a single worker:
hindsight-api (or hindsight-api --workers 1)
When workers=1, main.py creates the app directly and passes it to uvicorn.
These tests ensure that extensions are properly loaded in this code path.
Compare with test_server_module.py which tests the multi-worker path (workers > 1).
"""
import sys
from unittest.mock import MagicMock, patch
class TestMainModuleExtensionLoading:
"""Tests that main.py correctly loads extensions when configured via environment."""
def test_main_loads_tenant_extension_when_configured(self, monkeypatch):
"""
Verify that main.py loads tenant extension from HINDSIGHT_API_TENANT_EXTENSION.
This ensures extension loading works in the single-worker code path.
"""
# Set up environment to configure a tenant extension
monkeypatch.setenv(
"HINDSIGHT_API_TENANT_EXTENSION",
"tests.test_main_module:MockTenantExtension",
)
# Ensure single worker mode
monkeypatch.setenv("HINDSIGHT_API_WORKERS", "1")
# Track what extensions were loaded via load_extension
loaded_extensions = {}
# Get the real load_extension function
from hindsight_api.extensions.loader import load_extension as real_load_extension
def tracking_load_extension(name, base_class):
"""Track calls to load_extension and delegate to original."""
result = real_load_extension(name, base_class)
loaded_extensions[name] = result
return result
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \
patch("hindsight_api.main.DefaultExtensionContext"), \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"): # Don't actually start uvicorn
mock_config = MagicMock()
mock_config.host = "0.0.0.0"
mock_config.port = 8888
mock_config.log_level = "info"
mock_config.mcp_enabled = False
mock_config.run_migrations_on_startup = False
mock_config.database_url = "postgresql://test:test@localhost/test"
mock_get_config.return_value = mock_config
mock_engine.return_value = MagicMock()
mock_create_app.return_value = MagicMock()
# Mock sys.argv to simulate CLI invocation
with patch.object(sys, 'argv', ['hindsight-api']):
from hindsight_api.main import main
main()
# Verify TENANT extension was loaded
assert "TENANT" in loaded_extensions, \
"main.py did not call load_extension('TENANT', ...) - extensions not loaded!"
assert loaded_extensions["TENANT"] is not None, \
"load_extension('TENANT', ...) returned None despite env var being set"
assert isinstance(loaded_extensions["TENANT"], MockTenantExtension), \
f"Expected MockTenantExtension, got {type(loaded_extensions['TENANT'])}"
def test_main_loads_operation_validator_when_configured(self, monkeypatch):
"""
Verify that main.py loads operation validator from HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION.
"""
monkeypatch.setenv(
"HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION",
"tests.test_main_module:MockOperationValidator",
)
monkeypatch.setenv("HINDSIGHT_API_WORKERS", "1")
loaded_extensions = {}
from hindsight_api.extensions.loader import load_extension as real_load_extension
def tracking_load_extension(name, base_class):
result = real_load_extension(name, base_class)
loaded_extensions[name] = result
return result
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \
patch("hindsight_api.main.DefaultExtensionContext"), \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"):
mock_config = MagicMock()
mock_config.host = "0.0.0.0"
mock_config.port = 8888
mock_config.log_level = "info"
mock_config.mcp_enabled = False
mock_config.run_migrations_on_startup = False
mock_config.database_url = "postgresql://test:test@localhost/test"
mock_get_config.return_value = mock_config
mock_engine.return_value = MagicMock()
mock_create_app.return_value = MagicMock()
with patch.object(sys, 'argv', ['hindsight-api']):
from hindsight_api.main import main
main()
assert "OPERATION_VALIDATOR" in loaded_extensions, \
"main.py did not call load_extension('OPERATION_VALIDATOR', ...)"
assert loaded_extensions["OPERATION_VALIDATOR"] is not None
assert isinstance(loaded_extensions["OPERATION_VALIDATOR"], MockOperationValidator)
def test_main_passes_extensions_to_memory_engine(self, monkeypatch):
"""
Verify that main.py passes loaded extensions to MemoryEngine constructor.
This is the critical test - even if extensions are loaded, they must be
passed to MemoryEngine for authentication to work.
"""
monkeypatch.setenv(
"HINDSIGHT_API_TENANT_EXTENSION",
"tests.test_main_module:MockTenantExtension",
)
monkeypatch.setenv("HINDSIGHT_API_WORKERS", "1")
memory_engine_calls = []
def capture_memory_engine(*args, **kwargs):
memory_engine_calls.append({"args": args, "kwargs": kwargs})
return MagicMock()
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main.DefaultExtensionContext"), \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"):
mock_config = MagicMock()
mock_config.host = "0.0.0.0"
mock_config.port = 8888
mock_config.log_level = "info"
mock_config.mcp_enabled = False
mock_config.run_migrations_on_startup = False
mock_config.database_url = "postgresql://test:test@localhost/test"
mock_get_config.return_value = mock_config
mock_create_app.return_value = MagicMock()
with patch.object(sys, 'argv', ['hindsight-api']):
from hindsight_api.main import main
main()
# Verify MemoryEngine was called
assert len(memory_engine_calls) == 1, "MemoryEngine should be called exactly once"
call_kwargs = memory_engine_calls[0]["kwargs"]
# THE CRITICAL ASSERTION: tenant_extension must be passed and not None
assert "tenant_extension" in call_kwargs, \
"MemoryEngine was not called with tenant_extension parameter!"
assert call_kwargs["tenant_extension"] is not None, \
"tenant_extension was None - main.py did not pass loaded extension to MemoryEngine!"
def test_main_sets_extension_context_on_tenant_extension(self, monkeypatch):
"""
Verify that main.py sets the extension context on tenant extension.
This is required for tenant extensions that need to provision schemas.
"""
monkeypatch.setenv(
"HINDSIGHT_API_TENANT_EXTENSION",
"tests.test_main_module:MockTenantExtension",
)
monkeypatch.setenv("HINDSIGHT_API_WORKERS", "1")
captured_tenant_ext = [None]
def capture_memory_engine(*args, **kwargs):
captured_tenant_ext[0] = kwargs.get("tenant_extension")
return MagicMock()
context_created = []
def capture_context(*args, **kwargs):
ctx = MagicMock()
context_created.append(ctx)
return ctx
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main.DefaultExtensionContext", side_effect=capture_context), \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"):
mock_config = MagicMock()
mock_config.host = "0.0.0.0"
mock_config.port = 8888
mock_config.log_level = "info"
mock_config.mcp_enabled = False
mock_config.run_migrations_on_startup = False
mock_config.database_url = "postgresql://test:test@localhost/test"
mock_get_config.return_value = mock_config
mock_create_app.return_value = MagicMock()
with patch.object(sys, 'argv', ['hindsight-api']):
from hindsight_api.main import main
main()
# Verify context was created and set
assert len(context_created) == 1, "DefaultExtensionContext should be created"
assert captured_tenant_ext[0] is not None, "Tenant extension should be captured"
assert captured_tenant_ext[0]._context_set, \
"set_context was not called on tenant extension"
def test_main_works_without_extensions(self, monkeypatch):
"""
Verify that main.py works correctly when no extensions are configured.
"""
# Ensure no extension env vars are set
monkeypatch.delenv("HINDSIGHT_API_TENANT_EXTENSION", raising=False)
monkeypatch.delenv("HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION", raising=False)
monkeypatch.setenv("HINDSIGHT_API_WORKERS", "1")
memory_engine_calls = []
def capture_memory_engine(*args, **kwargs):
memory_engine_calls.append({"args": args, "kwargs": kwargs})
return MagicMock()
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"):
mock_config = MagicMock()
mock_config.host = "0.0.0.0"
mock_config.port = 8888
mock_config.log_level = "info"
mock_config.mcp_enabled = False
mock_config.run_migrations_on_startup = False
mock_config.database_url = "postgresql://test:test@localhost/test"
mock_get_config.return_value = mock_config
mock_create_app.return_value = MagicMock()
with patch.object(sys, 'argv', ['hindsight-api']):
from hindsight_api.main import main
main()
# Should work without extensions
assert len(memory_engine_calls) == 1
call_kwargs = memory_engine_calls[0]["kwargs"]
# Extensions should be None when not configured
assert call_kwargs.get("tenant_extension") is None
assert call_kwargs.get("operation_validator") is None
def test_main_uses_app_object_for_single_worker(self, monkeypatch):
"""
Verify that main.py passes the app object (not import string) when workers=1.
This is important because it means single-worker mode uses the app created
in main.py (with extensions loaded), not server.py.
"""
monkeypatch.setenv("HINDSIGHT_API_WORKERS", "1")
monkeypatch.delenv("HINDSIGHT_API_TENANT_EXTENSION", raising=False)
uvicorn_calls = []
def capture_uvicorn_run(**kwargs):
uvicorn_calls.append(kwargs)
mock_app = MagicMock()
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app", return_value=mock_app), \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run", side_effect=capture_uvicorn_run):
mock_config = MagicMock()
mock_config.host = "0.0.0.0"
mock_config.port = 8888
mock_config.log_level = "info"
mock_config.mcp_enabled = False
mock_config.run_migrations_on_startup = False
mock_config.database_url = "postgresql://test:test@localhost/test"
mock_get_config.return_value = mock_config
mock_engine.return_value = MagicMock()
with patch.object(sys, 'argv', ['hindsight-api', '--workers', '1']):
from hindsight_api.main import main
main()
assert len(uvicorn_calls) == 1
# With workers=1, should pass app object, not import string
assert uvicorn_calls[0]["app"] is mock_app, \
"main.py should pass app object (not import string) when workers=1"
def test_main_uses_import_string_for_multiple_workers(self, monkeypatch):
"""
Verify that main.py uses import string when workers > 1.
This is important because multi-worker mode requires server.py to be imported
by each worker process.
"""
monkeypatch.setenv("HINDSIGHT_API_WORKERS", "2")
monkeypatch.delenv("HINDSIGHT_API_TENANT_EXTENSION", raising=False)
uvicorn_calls = []
def capture_uvicorn_run(**kwargs):
uvicorn_calls.append(kwargs)
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run", side_effect=capture_uvicorn_run):
mock_config = MagicMock()
mock_config.host = "0.0.0.0"
mock_config.port = 8888
mock_config.log_level = "info"
mock_config.mcp_enabled = False
mock_config.run_migrations_on_startup = False
mock_config.database_url = "postgresql://test:test@localhost/test"
mock_get_config.return_value = mock_config
mock_engine.return_value = MagicMock()
mock_create_app.return_value = MagicMock()
with patch.object(sys, 'argv', ['hindsight-api', '--workers', '2']):
from hindsight_api.main import main
main()
assert len(uvicorn_calls) == 1
# With workers > 1, should use import string
assert uvicorn_calls[0]["app"] == "hindsight_api.server:app", \
"main.py should use import string when workers > 1"
assert uvicorn_calls[0]["workers"] == 2
# Mock extensions for testing
from hindsight_api.extensions import (
OperationValidatorExtension,
RecallContext,
ReflectContext,
RequestContext,
RetainContext,
TenantContext,
TenantExtension,
ValidationResult,
)
class MockTenantExtension(TenantExtension):
"""Mock tenant extension for testing main.py extension loading."""
def __init__(self, config: dict):
super().__init__(config)
self._context_set = False
async def authenticate(self, request_context: RequestContext) -> TenantContext:
return TenantContext(schema_name="public")
async def list_tenants(self) -> list:
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema="public")]
def set_context(self, context) -> None:
self._context_set = True
class MockOperationValidator(OperationValidatorExtension):
"""Mock operation validator for testing main.py extension loading."""
def __init__(self, config: dict):
super().__init__(config)
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
+55 -5
View File
@@ -62,9 +62,9 @@ async def test_local_mcp_server_recall(mock_memory):
tools = mcp_server._tool_manager._tools
assert "recall" in tools
# Call recall with new params
# Call recall
recall_tool = tools["recall"]
result = await recall_tool.fn(query="test query", max_tokens=2048, budget="mid")
result = await recall_tool.fn(query="test query", max_tokens=2048)
# Result is a dict
assert isinstance(result, dict)
@@ -75,7 +75,7 @@ async def test_local_mcp_server_recall(mock_memory):
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["query"] == "test query"
assert call_kwargs["max_tokens"] == 2048
assert call_kwargs["budget"] == Budget.MID
assert call_kwargs["budget"] == Budget.HIGH
@pytest.mark.asyncio
@@ -141,7 +141,7 @@ async def test_local_mcp_server_recall_error_handling(mock_memory):
@pytest.mark.asyncio
async def test_local_mcp_server_recall_with_defaults(mock_memory):
"""Test that recall uses default max_tokens and budget."""
"""Test that recall uses default max_tokens and HIGH budget."""
from hindsight_api.mcp_local import create_local_mcp_server
from hindsight_api.engine.memory_engine import Budget
@@ -159,4 +159,54 @@ async def test_local_mcp_server_recall_with_defaults(mock_memory):
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["max_tokens"] == 4096
assert call_kwargs["budget"] == Budget.LOW
assert call_kwargs["budget"] == Budget.HIGH
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_timestamp(mock_memory):
"""Test that retain passes timestamp as event_date."""
from datetime import datetime, timezone
from hindsight_api.mcp_local import create_local_mcp_server
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain with timestamp
result = await retain_tool.fn(
content="test content", context="test_context", timestamp="2024-01-15T10:30:00Z"
)
assert result["status"] == "accepted"
# Wait for background task
await asyncio.sleep(0.1)
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
contents = call_kwargs["contents"]
assert len(contents) == 1
assert contents[0]["content"] == "test content"
assert contents[0]["context"] == "test_context"
assert "event_date" in contents[0]
assert contents[0]["event_date"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_invalid_timestamp(mock_memory):
"""Test that retain rejects invalid timestamp format."""
from hindsight_api.mcp_local import create_local_mcp_server
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain with invalid timestamp
result = await retain_tool.fn(content="test content", timestamp="not-a-date")
assert result["status"] == "error"
assert "Invalid timestamp format" in result["message"]
# Verify retain_batch_async was NOT called
mock_memory.retain_batch_async.assert_not_called()
+63
View File
@@ -0,0 +1,63 @@
"""Tests for the shared MCP tools module."""
from datetime import datetime, timezone
import pytest
from hindsight_api.mcp_tools import build_content_dict, parse_timestamp
class TestParseTimestamp:
"""Tests for parse_timestamp function."""
def test_parse_iso_format_with_z(self):
"""Test parsing ISO format with Z suffix."""
result = parse_timestamp("2024-01-15T10:30:00Z")
assert result == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
def test_parse_iso_format_with_offset(self):
"""Test parsing ISO format with timezone offset."""
result = parse_timestamp("2024-01-15T10:30:00+00:00")
assert result == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
def test_parse_iso_format_without_tz(self):
"""Test parsing ISO format without timezone."""
result = parse_timestamp("2024-01-15T10:30:00")
assert result == datetime(2024, 1, 15, 10, 30, 0)
def test_parse_invalid_format_raises(self):
"""Test that invalid format raises ValueError."""
with pytest.raises(ValueError) as exc_info:
parse_timestamp("not-a-date")
assert "Invalid timestamp format" in str(exc_info.value)
class TestBuildContentDict:
"""Tests for build_content_dict function."""
def test_basic_content(self):
"""Test building content dict with just content and context."""
result, error = build_content_dict("test content", "test_context")
assert error is None
assert result == {"content": "test content", "context": "test_context"}
def test_with_valid_timestamp(self):
"""Test building content dict with valid timestamp."""
result, error = build_content_dict("test content", "test_context", "2024-01-15T10:30:00Z")
assert error is None
assert result["content"] == "test content"
assert result["context"] == "test_context"
assert result["event_date"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
def test_with_invalid_timestamp(self):
"""Test building content dict with invalid timestamp."""
result, error = build_content_dict("test content", "test_context", "invalid")
assert error is not None
assert "Invalid timestamp format" in error
assert result == {}
def test_with_none_timestamp(self):
"""Test building content dict with None timestamp."""
result, error = build_content_dict("test content", "test_context", None)
assert error is None
assert "event_date" not in result
+453
View File
@@ -0,0 +1,453 @@
"""Tests for directive functionality.
Directives are hard rules injected into prompts.
They are stored in the 'directives' table.
"""
import uuid
import pytest
from hindsight_api.engine.memory_engine import MemoryEngine
@pytest.fixture
async def memory_with_bank(memory: MemoryEngine, request_context):
"""Memory engine with a bank that has some data.
Uses a unique bank_id to avoid conflicts between parallel tests.
"""
# Use unique bank_id to avoid conflicts between parallel tests
bank_id = f"test-directives-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "The team has daily standups at 9am where everyone shares their progress."},
{"content": "Alice is the frontend engineer and specializes in React."},
{"content": "Bob is the backend engineer and owns the API services."},
],
request_context=request_context,
)
# Wait for any background tasks from retain to complete
await memory.wait_for_background_tasks()
yield memory, bank_id
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestBankMission:
"""Test bank mission operations."""
async def test_set_and_get_mission(self, memory: MemoryEngine, request_context):
"""Test setting and getting a bank's mission."""
bank_id = f"test-mission-{uuid.uuid4().hex[:8]}"
# Set mission
result = await memory.set_bank_mission(
bank_id=bank_id,
mission="Track customer feedback",
request_context=request_context,
)
assert result["bank_id"] == bank_id
assert result["mission"] == "Track customer feedback"
# Get mission via profile
profile = await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
assert profile["mission"] == "Track customer feedback"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestDirectives:
"""Test directive functionality."""
async def test_create_directive(self, memory: MemoryEngine, request_context):
"""Test creating a directive."""
bank_id = f"test-directive-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a directive
directive = await memory.create_directive(
bank_id=bank_id,
name="Competitor Policy",
content="Never mention competitor product names directly. If asked about competitors, redirect to our features.",
request_context=request_context,
)
assert directive["name"] == "Competitor Policy"
assert "Never mention competitor" in directive["content"]
assert directive["is_active"] is True
assert directive["priority"] == 0
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_directive_crud(self, memory: MemoryEngine, request_context):
"""Test basic CRUD operations for directives."""
bank_id = f"test-directive-crud-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create
directive = await memory.create_directive(
bank_id=bank_id,
name="Test Directive",
content="Follow this rule",
request_context=request_context,
)
directive_id = directive["id"]
# Read
retrieved = await memory.get_directive(
bank_id=bank_id,
directive_id=directive_id,
request_context=request_context,
)
assert retrieved is not None
assert retrieved["name"] == "Test Directive"
assert retrieved["content"] == "Follow this rule"
# List
directives = await memory.list_directives(
bank_id=bank_id,
request_context=request_context,
)
assert len(directives) == 1
assert directives[0]["id"] == directive_id
# Update
updated = await memory.update_directive(
bank_id=bank_id,
directive_id=directive_id,
content="Updated rule content",
request_context=request_context,
)
assert updated["content"] == "Updated rule content"
# Delete
deleted = await memory.delete_directive(
bank_id=bank_id,
directive_id=directive_id,
request_context=request_context,
)
assert deleted is True
# Verify deletion
retrieved_after = await memory.get_directive(
bank_id=bank_id,
directive_id=directive_id,
request_context=request_context,
)
assert retrieved_after is None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_directive_priority(self, memory: MemoryEngine, request_context):
"""Test that directive priority works correctly."""
bank_id = f"test-directive-priority-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create directives with different priorities
await memory.create_directive(
bank_id=bank_id,
name="Low Priority",
content="Low priority rule",
priority=1,
request_context=request_context,
)
await memory.create_directive(
bank_id=bank_id,
name="High Priority",
content="High priority rule",
priority=10,
request_context=request_context,
)
# List should order by priority (desc)
directives = await memory.list_directives(
bank_id=bank_id,
request_context=request_context,
)
assert len(directives) == 2
assert directives[0]["name"] == "High Priority"
assert directives[1]["name"] == "Low Priority"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_directive_is_active(self, memory: MemoryEngine, request_context):
"""Test that inactive directives are filtered by default."""
bank_id = f"test-directive-active-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create active and inactive directives
await memory.create_directive(
bank_id=bank_id,
name="Active Rule",
content="This is active",
is_active=True,
request_context=request_context,
)
await memory.create_directive(
bank_id=bank_id,
name="Inactive Rule",
content="This is inactive",
is_active=False,
request_context=request_context,
)
# List active only (default)
active_directives = await memory.list_directives(
bank_id=bank_id,
active_only=True,
request_context=request_context,
)
assert len(active_directives) == 1
assert active_directives[0]["name"] == "Active Rule"
# List all
all_directives = await memory.list_directives(
bank_id=bank_id,
active_only=False,
request_context=request_context,
)
assert len(all_directives) == 2
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestDirectiveTags:
"""Test tags functionality for directives."""
async def test_directive_with_tags(self, memory: MemoryEngine, request_context):
"""Test creating a directive with tags."""
bank_id = f"test-directive-tags-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a directive with tags
directive = await memory.create_directive(
bank_id=bank_id,
name="Tagged Rule",
content="Follow this rule",
tags=["project-a", "team-x"],
request_context=request_context,
)
assert directive["tags"] == ["project-a", "team-x"]
# Retrieve and verify tags
retrieved = await memory.get_directive(
bank_id=bank_id,
directive_id=directive["id"],
request_context=request_context,
)
assert retrieved["tags"] == ["project-a", "team-x"]
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_list_directives_by_tags(self, memory: MemoryEngine, request_context):
"""Test listing directives filtered by tags."""
bank_id = f"test-directive-tags-list-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create directives with different tags
await memory.create_directive(
bank_id=bank_id,
name="Rule A",
content="Rule for project A",
tags=["project-a"],
request_context=request_context,
)
await memory.create_directive(
bank_id=bank_id,
name="Rule B",
content="Rule for project B",
tags=["project-b"],
request_context=request_context,
)
# List all
all_directives = await memory.list_directives(
bank_id=bank_id,
request_context=request_context,
)
assert len(all_directives) == 2
# Filter by project-a tag
filtered = await memory.list_directives(
bank_id=bank_id,
tags=["project-a"],
request_context=request_context,
)
assert len(filtered) == 1
assert filtered[0]["name"] == "Rule A"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestReflect:
"""Test reflect endpoint."""
async def test_reflect_basic(self, memory_with_bank, request_context):
"""Test basic reflect query works."""
memory, bank_id = memory_with_bank
# Run a reflect query
result = await memory.reflect_async(
bank_id=bank_id,
query="Who are the team members?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
class TestDirectivesInReflect:
"""Test that directives are followed during reflect operations."""
async def test_reflect_follows_language_directive(self, memory: MemoryEngine, request_context):
"""Test that reflect follows a directive to respond in a specific language."""
bank_id = f"test-directive-reflect-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some content in English
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is a software engineer who works at Google."},
{"content": "Alice enjoys hiking on weekends and has been to Yosemite."},
{"content": "Alice is currently working on a machine learning project."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Create a directive to always respond in French
await memory.create_directive(
bank_id=bank_id,
name="Language Policy",
content="ALWAYS respond in French language. Never respond in English.",
request_context=request_context,
)
# Run reflect query
result = await memory.reflect_async(
bank_id=bank_id,
query="What does Alice do for work?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# Check that the response contains French words/patterns
# Common French words that would appear when talking about someone's job
french_indicators = [
"elle",
"travaille",
"est",
"une",
"le",
"la",
"qui",
"chez",
"logiciel",
"ingénieur",
"ingénieure",
"développeur",
"développeuse",
]
response_lower = result.text.lower()
# At least some French words should appear in the response
french_word_count = sum(1 for word in french_indicators if word in response_lower)
assert (
french_word_count >= 2
), f"Expected French response, but got: {result.text[:200]}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestDirectivesPromptInjection:
"""Test that directives are properly injected into the system prompt."""
def test_build_directives_section_empty(self):
"""Test that empty directives returns empty string."""
from hindsight_api.engine.reflect.prompts import build_directives_section
result = build_directives_section([])
assert result == ""
def test_build_directives_section_with_content(self):
"""Test that directives with content are formatted correctly."""
from hindsight_api.engine.reflect.prompts import build_directives_section
directives = [
{
"name": "Competitor Policy",
"content": "Never mention competitor names. Redirect to our features.",
}
]
result = build_directives_section(directives)
assert "## DIRECTIVES (MANDATORY)" in result
assert "Competitor Policy" in result
assert "Never mention competitor names" in result
assert "NEVER violate these directives" in result
def test_system_prompt_includes_directives(self):
"""Test that build_system_prompt_for_tools includes directives."""
from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools
bank_profile = {"name": "Test Bank", "mission": "Test mission"}
directives = [
{
"name": "Test Directive",
"content": "Follow this rule",
}
]
prompt = build_system_prompt_for_tools(
bank_profile=bank_profile,
directives=directives,
)
assert "## DIRECTIVES (MANDATORY)" in prompt
assert "Follow this rule" in prompt
# Directives should appear before CRITICAL RULES
directives_pos = prompt.find("## DIRECTIVES")
critical_rules_pos = prompt.find("## CRITICAL RULES")
assert directives_pos < critical_rules_pos
+159
View File
@@ -275,6 +275,165 @@ async def test_retain_japanese_content(memory, request_context):
pass
@pytest.mark.asyncio
async def test_english_content_stays_english(memory, request_context):
"""
Test that English content is NOT incorrectly translated to Japanese or Chinese.
This test specifically catches the bug where the language instruction in the
CONCISE extraction prompt mentioned Japanese/Chinese explicitly, which primed
the LLM to sometimes output facts in those languages even for English input.
See: https://github.com/vectorize-io/hindsight/issues/181
"""
bank_id = f"test_english_retain_{datetime.now(timezone.utc).timestamp()}"
try:
# English content about a developer
english_content = """
John Smith is a software engineer at TechCorp in Seattle.
He specializes in machine learning and has been working on
recommendation systems for the past three years.
Last month, he launched a new feature that improved click-through rates by 25%.
He prefers working in Python and uses PyTorch for model training.
"""
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=english_content,
context="Team profile",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
logger.info(f"Retained {len(unit_ids)} facts from English content")
assert len(unit_ids) > 0, "Should have extracted facts from English content"
# Recall with English query
result = await memory.recall_async(
bank_id=bank_id,
query="Tell me about John Smith",
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
request_context=request_context,
)
assert len(result.results) > 0, "Should recall facts about John Smith"
# Verify facts are NOT in Japanese or Chinese
for fact in result.results:
logger.info(f"Fact: {fact.text}")
# Count Japanese characters (hiragana, katakana)
japanese_chars = sum(
1 for char in fact.text
if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff")
)
# Count Chinese/CJK characters (excluding those also used in Japanese)
# Note: Kanji/CJK ideographs overlap between Chinese and Japanese
cjk_chars = sum(1 for char in fact.text if "\u4e00" <= char <= "\u9fff")
# For English input, there should be minimal CJK characters
# Allow for occasional edge cases (e.g., proper nouns) but not full translation
total_chars = len(fact.text)
cjk_ratio = cjk_chars / max(total_chars, 1)
assert cjk_ratio < 0.1, (
f"English content was incorrectly translated to CJK language! "
f"CJK ratio: {cjk_ratio:.1%}, Japanese chars: {japanese_chars}, CJK chars: {cjk_chars}. "
f"Fact: {fact.text}"
)
logger.info("English content test passed - facts stayed in English")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_italian_content_stays_italian(memory, request_context):
"""
Test that Italian content is NOT incorrectly translated to Japanese or Chinese.
Similar to the English test, this catches the bug where non-CJK languages
could be incorrectly translated due to biased language instruction.
See: https://github.com/vectorize-io/hindsight/issues/181
"""
bank_id = f"test_italian_retain_{datetime.now(timezone.utc).timestamp()}"
try:
# Italian content about a chef
italian_content = """
Marco Rossi è uno chef italiano che lavora in un ristorante a Milano.
È specializzato nella cucina toscana e ha vinto tre premi gastronomici.
Il mese scorso ha aperto un nuovo ristorante nel centro della città.
Preferisce usare ingredienti freschi e locali per i suoi piatti.
"""
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=italian_content,
context="Profilo dello chef",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
logger.info(f"Retained {len(unit_ids)} facts from Italian content")
assert len(unit_ids) > 0, "Should have extracted facts from Italian content"
# Recall with Italian query
result = await memory.recall_async(
bank_id=bank_id,
query="Dimmi di Marco Rossi", # "Tell me about Marco Rossi"
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
request_context=request_context,
)
assert len(result.results) > 0, "Should recall facts about Marco Rossi"
# Verify facts are NOT in Japanese or Chinese - should stay in Italian
for fact in result.results:
logger.info(f"Fact: {fact.text}")
# Count CJK characters
cjk_chars = sum(1 for char in fact.text if "\u4e00" <= char <= "\u9fff")
japanese_chars = sum(
1 for char in fact.text
if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff")
)
total_chars = len(fact.text)
cjk_ratio = (cjk_chars + japanese_chars) / max(total_chars, 1)
assert cjk_ratio < 0.1, (
f"Italian content was incorrectly translated to CJK language! "
f"CJK ratio: {cjk_ratio:.1%}. Fact: {fact.text}"
)
# Verify facts contain Italian words (basic sanity check)
all_text = " ".join(f.text for f in result.results).lower()
italian_indicators = ["marco", "rossi", "chef", "ristorante", "milano", "cucina", "italiano", "italiana"]
has_italian = any(word in all_text for word in italian_indicators)
# Allow English translation as acceptable (not ideal but not the bug)
english_indicators = ["chef", "restaurant", "milan", "italian", "cooking"]
has_english = any(word in all_text for word in english_indicators)
assert has_italian or has_english, (
f"Expected facts to be in Italian or English, but got neither. Facts: {all_text}"
)
logger.info("Italian content test passed - facts not translated to CJK")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_mixed_language_entities(memory, request_context):
"""
+246 -186
View File
@@ -1,5 +1,9 @@
"""
Test observation generation and entity state functionality.
NOTE: Observations are now stored as summaries on the entities table,
not as separate memory_units. The observations list in EntityState is
populated from the summary for backwards compatibility.
"""
import pytest
from hindsight_api.engine.memory_engine import Budget
@@ -8,21 +12,16 @@ from datetime import datetime, timezone
@pytest.mark.asyncio
async def test_observation_generation_on_put(memory, request_context):
async def test_entity_extraction_on_retain(memory, request_context):
"""
Test that observations are generated SYNCHRONOUSLY when new facts are added.
Test that entities are extracted when new facts are added.
Observations are generated during retain when:
- Entity has >= 5 facts (MIN_FACTS_THRESHOLD)
- Entity is in top 5 by mention count
This test stores enough facts to trigger automatic observation generation.
This test stores multiple facts and verifies entities are extracted.
"""
bank_id = f"test_obs_{datetime.now(timezone.utc).timestamp()}"
bank_id = f"test_entity_extraction_{datetime.now(timezone.utc).timestamp()}"
try:
# Store multiple facts about John to reach the MIN_FACTS_THRESHOLD (5)
# Each retain call should extract at least one fact about John
# Store multiple facts about John
contents = [
"John is a software engineer at Google.",
"John is detail-oriented and methodical in his work.",
@@ -41,9 +40,8 @@ async def test_observation_generation_on_put(memory, request_context):
request_context=request_context,
)
# Observations are generated SYNCHRONOUSLY during retain,
# so they should be available immediately after retain completes.
# No need to wait for background tasks for observations.
# Wait for background tasks
await memory.wait_for_background_tasks()
# Find the John entity
pool = await memory._get_pool()
@@ -58,7 +56,7 @@ async def test_observation_generation_on_put(memory, request_context):
bank_id
)
# Also check the fact count for this entity
# Check the fact count for this entity
if entity_row:
fact_count = await conn.fetchval(
"""
@@ -70,30 +68,9 @@ async def test_observation_generation_on_put(memory, request_context):
print(f"Entity: {entity_row['canonical_name']} has {fact_count} linked facts")
assert entity_row is not None, "John entity should have been extracted"
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
print(f"\n=== Found Entity ===")
print(f"Entity: {entity_name} (id: {entity_id})")
# Get observations for the entity - should be available immediately
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"\n=== Observations for {entity_name} ===")
print(f"Total observations: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Verify observations were created (requires >= 5 facts)
assert len(observations) > 0, \
f"Observations should have been generated synchronously during retain (entity has {fact_count} facts, threshold is 5)"
# Check that observations mention relevant content
obs_texts = " ".join([o.text.lower() for o in observations])
assert any(keyword in obs_texts for keyword in ["google", "engineer", "ai", "machine learning", "detail"]), \
"Observations should contain relevant information about John"
print(f"✓ Observations were successfully generated synchronously during retain")
print(f"Entity: {entity_row['canonical_name']} (id: {entity_row['id']})")
print(f"Entity was successfully extracted")
finally:
# Cleanup
@@ -106,7 +83,7 @@ async def test_observation_generation_on_put(memory, request_context):
@pytest.mark.asyncio
async def test_regenerate_entity_observations(memory, request_context):
"""
Test explicit regeneration of observations for an entity.
Test explicit regeneration of summary for an entity.
"""
bank_id = f"test_regen_obs_{datetime.now(timezone.utc).timestamp()}"
@@ -139,7 +116,7 @@ async def test_regenerate_entity_observations(memory, request_context):
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Manually regenerate observations
# Manually regenerate summary (via observations API for backwards compat)
created_ids = await memory.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
@@ -147,23 +124,25 @@ async def test_regenerate_entity_observations(memory, request_context):
request_context=request_context,
)
print(f"\n=== Regenerated Observations ===")
print(f"Created {len(created_ids)} observations for {entity_name}")
print(f"\n=== Regenerated Summary ===")
print(f"Created {len(created_ids)} summary for {entity_name}")
# Get the observations
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
for obs in observations:
# Get entity state
state = await memory.get_entity_state(
bank_id, entity_id, entity_name, request_context=request_context
)
for obs in state.observations:
print(f" - {obs.text}")
# Verify observations were created
# Verify summary was created
if len(created_ids) > 0:
assert len(observations) == len(created_ids), "Should have same number of observations as created IDs"
print(f"✓ Observations regenerated successfully")
assert len(state.observations) == 1, "Should have exactly 1 observation (the summary)"
print(f"Summary regenerated successfully")
else:
print(f"Note: No observations were regenerated")
print(f"Note: No summary was regenerated")
else:
print(f"Note: No 'Sarah' entity was extracted")
print(f"Note: No 'Sarah' entity was extracted")
finally:
# Cleanup
@@ -174,19 +153,14 @@ async def test_regenerate_entity_observations(memory, request_context):
@pytest.mark.asyncio
async def test_manual_regenerate_with_few_facts(memory, request_context):
async def test_entity_state_retrieval(memory, request_context):
"""
Test that manual regeneration works even with fewer than 5 facts.
This is important because:
- Automatic generation during retain requires MIN_FACTS_THRESHOLD (5)
- But manual regeneration via API should work with any number of facts
- The UI triggers manual regeneration, so it should work regardless of fact count
Test retrieving entity state with facts.
"""
bank_id = f"test_manual_regen_{datetime.now(timezone.utc).timestamp()}"
bank_id = f"test_entity_state_{datetime.now(timezone.utc).timestamp()}"
try:
# Store only 2 facts - below the automatic threshold
# Store facts
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a senior software engineer.",
@@ -220,51 +194,25 @@ async def test_manual_regenerate_with_few_facts(memory, request_context):
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Check fact count - should be < 5
# Check fact count
async with pool.acquire() as conn:
fact_count = await conn.fetchval(
"SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1",
entity_row['id']
)
print(f"\n=== Manual Regeneration Test ===")
print(f"\n=== Entity State Test ===")
print(f"Entity: {entity_name} (id: {entity_id})")
print(f"Linked facts: {fact_count}")
# Verify we're testing with fewer than the automatic threshold
assert fact_count < 5, f"Test requires < 5 facts, but entity has {fact_count}"
# Before regeneration - should have no observations (auto threshold not met)
obs_before = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"Observations before manual regenerate: {len(obs_before)}")
# Manually regenerate observations - this should work regardless of fact count
created_ids = await memory.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
entity_name=entity_name,
request_context=request_context,
# Get entity state
state = await memory.get_entity_state(
bank_id, entity_id, entity_name, request_context=request_context
)
print(f"Observations created by manual regenerate: {len(created_ids)}")
# Get observations after regeneration
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"Observations after manual regenerate: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Manual regeneration should create observations even with < 5 facts
assert len(observations) > 0, \
f"Manual regeneration should create observations even with only {fact_count} facts. " \
f"The LLM should synthesize at least 1 observation from the available facts."
# Verify observations contain relevant content
obs_texts = " ".join([o.text.lower() for o in observations])
assert any(keyword in obs_texts for keyword in ["google", "engineer", "hiking", "photography", "alice"]), \
"Observations should contain relevant information about Alice"
print(f"✓ Manual regeneration works with {fact_count} facts (below automatic threshold of 5)")
assert state.entity_id == entity_id
assert state.canonical_name == entity_name
print(f"Entity state retrieved successfully")
finally:
# Cleanup
@@ -277,16 +225,16 @@ async def test_manual_regenerate_with_few_facts(memory, request_context):
@pytest.mark.asyncio
async def test_search_with_include_entities(memory, request_context):
"""
Test that search with include_entities=True returns entity observations.
Test that search with include_entities=True returns entity information.
This test verifies that:
1. Observations are generated during retain (when entity has >= 5 facts)
2. Observations are returned in recall results with include_entities=True
1. Entities are extracted after retain
2. Entity info is returned in recall results with include_entities=True
"""
bank_id = f"test_search_ent_{datetime.now(timezone.utc).timestamp()}"
try:
# Store enough facts about Alice to trigger observation generation (>= 5 facts)
# Store facts about Alice
contents = [
"Alice is a data scientist who works on recommendation systems at Netflix.",
"Alice presented her research at the ML conference last month.",
@@ -305,7 +253,8 @@ async def test_search_with_include_entities(memory, request_context):
request_context=request_context,
)
# Observations are generated synchronously during retain, no need to wait
# Wait for background tasks
await memory.wait_for_background_tasks()
# Search with include_entities=True
result = await memory.recall_async(
@@ -315,7 +264,7 @@ async def test_search_with_include_entities(memory, request_context):
budget=Budget.LOW,
max_tokens=2000,
include_entities=True,
max_entity_tokens=500,
max_entity_tokens=5000,
request_context=request_context,
)
@@ -326,40 +275,28 @@ async def test_search_with_include_entities(memory, request_context):
if fact.entities:
print(f" Entities: {', '.join(fact.entities)}")
print(f"\n=== Entity Observations in Recall ===")
if result.entities:
for name, state in result.entities.items():
print(f"\n{name}:")
for obs in state.observations:
print(f" - {obs.text}")
else:
print("No entity observations returned")
# Verify results
assert len(result.results) > 0, "Should find some facts"
# Check if entities are included in facts
facts_with_entities = [f for f in result.results if f.entities]
assert len(facts_with_entities) > 0, "Some facts should have entity information"
print(f"{len(facts_with_entities)} facts have entity information")
print(f"{len(facts_with_entities)} facts have entity information")
# Check if entity observations are included in recall
assert result.entities is not None and len(result.entities) > 0, \
"Entity observations should be included in recall results"
print(f"✓ Entity observations included for {len(result.entities)} entities")
# Check if entity info is returned
if result.entities:
print(f"Entity info included for {len(result.entities)} entities")
# Verify Alice entity has observations
alice_found = False
for name, state in result.entities.items():
assert state.canonical_name == name, "Entity canonical_name should match key"
assert state.entity_id, "Entity should have an ID"
if "alice" in name.lower():
alice_found = True
assert len(state.observations) > 0, \
"Alice should have observations (generated during retain)"
print(f"✓ Alice has {len(state.observations)} observations in recall result")
# Verify Alice entity is in results
alice_found = False
for name, state in result.entities.items():
assert state.canonical_name == name, "Entity canonical_name should match key"
assert state.entity_id, "Entity should have an ID"
if "alice" in name.lower():
alice_found = True
print(f"Alice entity found: {name}")
assert alice_found, "Alice entity should be in recall results"
assert alice_found, "Alice entity should be in recall results"
finally:
# Cleanup
@@ -435,7 +372,10 @@ async def test_get_entity_state(memory, request_context):
@pytest.mark.asyncio
async def test_observation_fact_type_in_database(memory, request_context):
"""
Test that observations are stored with correct fact_type in database.
Test that observations are NOT stored as memory_units with fact_type='observation'.
NOTE: Observations are now handled via mental models, not as memory_units
or entity summaries.
"""
bank_id = f"test_obs_db_{datetime.now(timezone.utc).timestamp()}"
@@ -451,7 +391,7 @@ async def test_observation_fact_type_in_database(memory, request_context):
await memory.wait_for_background_tasks()
# Check that observations have correct fact_type
# Check that NO observations exist in memory_units
pool = await memory._get_pool()
async with pool.acquire() as conn:
observations = await conn.fetch(
@@ -463,17 +403,11 @@ async def test_observation_fact_type_in_database(memory, request_context):
bank_id
)
print(f"\n=== Observation Records in Database ===")
print(f"Found {len(observations)} observation records")
for obs in observations:
print(f" - fact_type: {obs['fact_type']}")
print(f" text: {obs['text']}")
print(f" context: {obs['context']}")
print(f"\n=== Observation Records in memory_units ===")
print(f"Found {len(observations)} observation records (should be 0)")
if len(observations) > 0:
for obs in observations:
assert obs['fact_type'] == 'observation', "All observation records should have fact_type='observation'"
print(f"✓ All observations have correct fact_type")
# Observations are no longer stored as memory_units
assert len(observations) == 0, "Observations should NOT be stored as memory_units"
finally:
# Cleanup
@@ -484,23 +418,183 @@ async def test_observation_fact_type_in_database(memory, request_context):
@pytest.mark.asyncio
async def test_user_entity_prioritized_for_observations(memory, request_context):
async def test_entity_mention_counts(memory, request_context):
"""
Test that the 'user' entity gets observations even when many other entities exist.
Test that entity mention counts are tracked correctly.
The retain pipeline only regenerates observations for TOP_N_ENTITIES (5) entities,
sorted by mention count. This test verifies that the most mentioned entity ('user')
gets prioritized and receives observations.
This is critical because 'user' is often the most important entity in personal memory.
This test creates entities with varying mention counts and verifies
that the counts are accurate.
"""
bank_id = f"test_user_priority_{datetime.now(timezone.utc).timestamp()}"
bank_id = f"test_mention_counts_{datetime.now(timezone.utc).timestamp()}"
try:
# Create content where 'user' (the user) is mentioned many times
# along with several other entities
# Create content with varying entity mention counts:
# - "HighMention Corp" mentioned 10+ times
# - "LowMention Ltd" mentioned 1 time
contents = [
# High mentions - HighMention Corp
"HighMention Corp is a tech company based in San Francisco.",
"HighMention Corp was founded in 2010 by experienced entrepreneurs.",
"HighMention Corp has over 500 employees worldwide.",
"HighMention Corp specializes in cloud computing solutions.",
"HighMention Corp recently raised $50 million in Series C funding.",
"HighMention Corp has partnerships with major tech companies.",
"HighMention Corp is known for its innovative culture.",
"HighMention Corp offers competitive salaries and benefits.",
"HighMention Corp has offices in 5 countries.",
"HighMention Corp won the best workplace award last year.",
# Low mentions - LowMention Ltd
"LowMention Ltd is a small consulting firm.",
]
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="company info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
)
# Wait for background tasks
await memory.wait_for_background_tasks()
# Check entity mention counts
pool = await memory._get_pool()
async with pool.acquire() as conn:
entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name, e.mention_count
FROM entities e
WHERE e.bank_id = $1
ORDER BY e.mention_count DESC
""",
bank_id
)
print(f"\n=== Entity Mention Counts Test ===")
print(f"Total entities: {len(entities)}")
high_mention_entity = None
low_mention_entity = None
for entity in entities:
name = entity['canonical_name'].lower()
mention_count = entity['mention_count']
print(f" {entity['canonical_name']}: mentions={mention_count}")
if "highmention" in name:
high_mention_entity = entity
elif "lowmention" in name:
low_mention_entity = entity
# Verify HighMention Corp has higher mention count
if high_mention_entity and low_mention_entity:
assert high_mention_entity['mention_count'] > low_mention_entity['mention_count'], \
"HighMention Corp should have more mentions than LowMention Ltd"
print("PASS: Entity mention counts are tracked correctly")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_entity_mention_ranking(memory, request_context):
"""
Test that entity mention counts correctly rank entities.
This test:
1. Creates an entity with 6 mentions
2. Adds more entities with higher mention counts
3. Verifies entities are ranked correctly by mention count
"""
bank_id = f"test_ranking_{datetime.now(timezone.utc).timestamp()}"
try:
# Phase 1: Create "OriginalEntity" with 6 mentions
print("\n=== Phase 1: Create OriginalEntity with 6 mentions ===")
for i in range(6):
await memory.retain_async(
bank_id=bank_id,
content=f"OriginalEntity is mentioned here in fact {i+1}.",
context="test",
event_date=datetime(2024, 1, 1 + i, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Phase 2: Add more entities with MORE mentions
print("\n=== Phase 2: Add entities with 10+ mentions each ===")
for entity_num in range(3): # Reduced from 10 to 3 to speed up test
entity_name = f"NewEntity{entity_num}"
for mention in range(10):
await memory.retain_async(
bank_id=bank_id,
content=f"{entity_name} is a very important entity, mention {mention+1}.",
context="test",
event_date=datetime(2024, 2, 1 + mention, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Phase 3: Verify entities are ranked by mention count
print("\n=== Phase 3: Check entity ranking ===")
pool = await memory._get_pool()
async with pool.acquire() as conn:
all_entities = await conn.fetch(
"""
SELECT canonical_name, mention_count
FROM entities
WHERE bank_id = $1
ORDER BY mention_count DESC
""",
bank_id
)
print(f"\nAll entities by mention count:")
for e in all_entities:
print(f" {e['canonical_name']}: mentions={e['mention_count']}")
# Verify new entities have higher counts than OriginalEntity
original = next((e for e in all_entities if 'originalentity' in e['canonical_name'].lower()), None)
new_entities = [e for e in all_entities if 'newentity' in e['canonical_name'].lower()]
assert original is not None, "OriginalEntity should exist"
assert len(new_entities) > 0, "NewEntity entities should exist"
# Verify entities are created and have mention counts
# Note: LLM may merge mentions, so we just check that new entities exist
print(f"OriginalEntity mentions: {original['mention_count']}")
for new_entity in new_entities:
print(f"{new_entity['canonical_name']} mentions: {new_entity['mention_count']}")
print("PASS: Entities are created with mention counts tracked")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_user_entity_extraction(memory, request_context):
"""
Test that the 'user' entity is correctly extracted when mentioned frequently.
"""
bank_id = f"test_user_entity_{datetime.now(timezone.utc).timestamp()}"
try:
# Create content where 'user' is mentioned many times
contents = [
# User mentioned frequently
"The user loves hiking in the mountains during summer.",
"The user works as a software engineer at Microsoft.",
"The user has a dog named Max who is a golden retriever.",
@@ -510,11 +604,8 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
# Other entities mentioned fewer times
"Sarah is a friend who works at Google.",
"Bob is a colleague from the data science team.",
"Tokyo is a city the user visited last year.",
"Python is the user's favorite programming language.",
]
# Retain all content in a single batch for efficiency
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
@@ -524,12 +615,12 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
request_context=request_context,
)
# Observations are generated synchronously during retain
# Wait for background tasks
await memory.wait_for_background_tasks()
# Find the 'user' entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
# Find user entity (may be named "user", "the user", etc.)
user_entity = await conn.fetchrow(
"""
SELECT e.id, e.canonical_name,
@@ -544,7 +635,7 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
bank_id
)
# Get all entities with their fact counts to verify prioritization
# Get all entities with their fact counts
all_entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name,
@@ -564,41 +655,10 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
# Verify user entity exists
assert user_entity is not None, "User entity should have been extracted"
user_entity_id = str(user_entity['id'])
user_entity_name = user_entity['canonical_name']
user_fact_count = user_entity['fact_count']
print(f"\n=== User Entity ===")
print(f"Entity: {user_entity_name} (id: {user_entity_id})")
print(f"Fact count: {user_fact_count}")
# Verify user has enough facts for observations (>= MIN_FACTS_THRESHOLD of 5)
assert user_fact_count >= 5, \
f"User entity should have at least 5 facts, but has {user_fact_count}"
# Get observations for user entity
observations = await memory.get_entity_observations(bank_id, user_entity_id, limit=10, request_context=request_context)
print(f"\n=== User Entity Observations ===")
print(f"Total observations: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Verify observations were generated for user (critical assertion)
assert len(observations) > 0, \
f"User entity should have observations (has {user_fact_count} facts, threshold is 5). " \
f"This may indicate that 'user' is not being prioritized in the top 5 entities by mention count."
# Verify observations mention relevant content about the user
obs_texts = " ".join([o.text.lower() for o in observations])
user_keywords = ["hiking", "software", "engineer", "dog", "max", "cooking",
"italian", "mit", "dune", "microsoft"]
matching_keywords = [k for k in user_keywords if k in obs_texts]
assert len(matching_keywords) > 0, \
f"Observations should contain relevant information about the user. Keywords found: {matching_keywords}"
print(f"✓ User entity was prioritized and received {len(observations)} observations")
print(f"✓ Observations contain relevant keywords: {matching_keywords}")
print(f"Entity: {user_entity['canonical_name']} (id: {user_entity['id']})")
print(f"Fact count: {user_entity['fact_count']}")
print(f"User entity was successfully extracted")
finally:
# Cleanup
+329
View File
@@ -0,0 +1,329 @@
"""
Tests for the reflect agent with mocked LLM outputs.
These tests verify:
1. Tool name normalization for various LLM output formats
2. Recovery from unknown tool calls
3. Recovery from tool execution errors
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from hindsight_api.engine.reflect.agent import (
_normalize_tool_name,
_is_done_tool,
_clean_answer_text,
run_reflect_agent,
)
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
class TestCleanAnswerText:
"""Test cleanup of answer text that includes done() tool call syntax."""
def test_clean_text_with_done_call(self):
"""Text ending with done() call should have it stripped."""
text = '''The team's OKRs focus on performance.done({"answer":"The team's OKRs","memory_ids":[]})'''
cleaned = _clean_answer_text(text)
assert cleaned == "The team's OKRs focus on performance."
assert "done(" not in cleaned
def test_clean_text_with_done_call_and_whitespace(self):
"""done() call with whitespace should be stripped."""
text = '''Answer text here. done( {"answer": "short", "memory_ids": []} )'''
cleaned = _clean_answer_text(text)
assert cleaned == "Answer text here."
def test_clean_text_without_done_call(self):
"""Text without done() call should be unchanged."""
text = "This is a normal answer without any tool calls."
cleaned = _clean_answer_text(text)
assert cleaned == text
def test_clean_text_with_done_word_in_content(self):
"""The word 'done' in regular text should not be stripped."""
text = "The task is done and completed successfully."
cleaned = _clean_answer_text(text)
assert cleaned == text
def test_clean_empty_text(self):
"""Empty text should return empty."""
assert _clean_answer_text("") == ""
def test_clean_text_multiline_done(self):
"""done() call spanning multiple lines should be stripped."""
text = '''Summary of findings.done({
"answer": "Summary",
"memory_ids": ["id1", "id2"]
})'''
cleaned = _clean_answer_text(text)
assert cleaned == "Summary of findings."
class TestToolNameNormalization:
"""Test tool name normalization for various LLM output formats."""
def test_normalize_standard_name(self):
"""Standard tool names should pass through unchanged."""
assert _normalize_tool_name("done") == "done"
assert _normalize_tool_name("recall") == "recall"
assert _normalize_tool_name("search_reflections") == "search_reflections"
assert _normalize_tool_name("search_mental_models") == "search_mental_models"
assert _normalize_tool_name("expand") == "expand"
def test_normalize_functions_prefix(self):
"""Tool names with 'functions.' prefix should be normalized."""
assert _normalize_tool_name("functions.done") == "done"
assert _normalize_tool_name("functions.recall") == "recall"
assert _normalize_tool_name("functions.search_reflections") == "search_reflections"
def test_normalize_call_equals_prefix(self):
"""Tool names with 'call=' prefix should be normalized."""
assert _normalize_tool_name("call=done") == "done"
assert _normalize_tool_name("call=recall") == "recall"
def test_normalize_call_equals_functions_prefix(self):
"""Tool names with 'call=functions.' prefix should be normalized."""
assert _normalize_tool_name("call=functions.done") == "done"
assert _normalize_tool_name("call=functions.recall") == "recall"
assert _normalize_tool_name("call=functions.search_mental_models") == "search_mental_models"
def test_is_done_tool(self):
"""Test _is_done_tool helper."""
# Standard
assert _is_done_tool("done") is True
assert _is_done_tool("recall") is False
# With prefixes
assert _is_done_tool("functions.done") is True
assert _is_done_tool("call=done") is True
assert _is_done_tool("call=functions.done") is True
# Not done
assert _is_done_tool("functions.recall") is False
assert _is_done_tool("call=functions.recall") is False
class TestReflectAgentMocked:
"""Test reflect agent with mocked LLM outputs."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM provider."""
llm = MagicMock()
llm.call_with_tools = AsyncMock()
# Also mock call() for final iteration fallback - returns (response, usage) tuple
llm.call = AsyncMock(
return_value=("Fallback answer from final iteration", TokenUsage(input_tokens=100, output_tokens=50, total_tokens=150))
)
return llm
@pytest.fixture
def mock_functions(self):
"""Create mock search/recall functions."""
return {
"search_reflections_fn": AsyncMock(return_value={"reflections": []}),
"search_mental_models_fn": AsyncMock(return_value={"mental_models": []}),
"recall_fn": AsyncMock(return_value={"memories": [{"id": "mem-1", "content": "test memory"}]}),
"expand_fn": AsyncMock(return_value={"memories": []}),
}
@pytest.mark.asyncio
async def test_handles_functions_prefix_in_done(self, mock_llm, mock_functions):
"""Test that 'functions.done' is handled correctly."""
# First call: LLM calls recall
# Second call: LLM calls functions.done
mock_llm.call_with_tools.side_effect = [
LLMToolCallResult(
tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[
LLMToolCall(
id="2",
name="functions.done",
arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
)
],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
**mock_functions,
)
assert result.text == "Test answer"
assert "mem-1" in result.used_memory_ids
@pytest.mark.asyncio
async def test_handles_call_equals_functions_prefix(self, mock_llm, mock_functions):
"""Test that 'call=functions.done' is handled correctly."""
mock_llm.call_with_tools.side_effect = [
LLMToolCallResult(
tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[
LLMToolCall(
id="2",
name="call=functions.done",
arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
)
],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
**mock_functions,
)
assert result.text == "Test answer"
@pytest.mark.asyncio
async def test_recovery_from_unknown_tool(self, mock_llm, mock_functions):
"""Test that LLM can recover after calling an unknown tool."""
# First call: LLM calls unknown tool
# Second call: LLM calls valid recall after seeing error
# Third call: LLM calls done
mock_llm.call_with_tools.side_effect = [
LLMToolCallResult(
tool_calls=[LLMToolCall(id="1", name="invalid_tool", arguments={"foo": "bar"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[
LLMToolCall(
id="3",
name="done",
arguments={"answer": "Recovered successfully", "memory_ids": ["mem-1"]},
)
],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
**mock_functions,
)
assert result.text == "Recovered successfully"
# Verify the LLM was called 3 times (initial + recovery + done)
assert mock_llm.call_with_tools.call_count == 3
@pytest.mark.asyncio
async def test_recovery_from_tool_execution_error(self, mock_llm, mock_functions):
"""Test that LLM can recover after a tool execution fails."""
# Make recall fail the first time, succeed the second time
mock_functions["recall_fn"].side_effect = [
Exception("Database connection failed"),
{"memories": [{"id": "mem-1", "content": "test memory"}]},
]
mock_llm.call_with_tools.side_effect = [
LLMToolCallResult(
tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
finish_reason="tool_calls",
),
# LLM tries again after seeing error
LLMToolCallResult(
tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test retry"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[
LLMToolCall(
id="3",
name="done",
arguments={"answer": "Recovered from error", "memory_ids": ["mem-1"]},
)
],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
**mock_functions,
)
assert result.text == "Recovered from error"
assert mock_llm.call_with_tools.call_count == 3
@pytest.mark.asyncio
async def test_normalizes_tool_names_in_other_tools(self, mock_llm, mock_functions):
"""Test that tool names are normalized for all tools, not just done."""
mock_llm.call_with_tools.side_effect = [
# LLM calls 'functions.recall' instead of 'recall'
LLMToolCallResult(
tool_calls=[LLMToolCall(id="1", name="functions.recall", arguments={"query": "test"})],
finish_reason="tool_calls",
),
LLMToolCallResult(
tool_calls=[
LLMToolCall(
id="2",
name="done",
arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
)
],
finish_reason="tool_calls",
),
]
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
**mock_functions,
)
assert result.text == "Test answer"
# Verify recall was actually called (normalization worked)
mock_functions["recall_fn"].assert_called_once()
@pytest.mark.asyncio
async def test_max_iterations_reached(self, mock_llm, mock_functions):
"""Test that agent stops after max iterations even with errors."""
# LLM keeps calling unknown tools
mock_llm.call_with_tools.return_value = LLMToolCallResult(
tool_calls=[LLMToolCall(id="1", name="unknown_tool", arguments={})],
finish_reason="tool_calls",
)
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
max_iterations=3,
**mock_functions,
)
# Should have a result even if no memories found
assert result is not None
assert result.iterations == 3
+359
View File
@@ -0,0 +1,359 @@
"""Tests for reflections, mental models, and learnings functionality."""
import uuid
import pytest
import pytest_asyncio
import httpx
from hindsight_api.api import create_app
from hindsight_api.engine.memory_engine import MemoryEngine
@pytest_asyncio.fixture
async def api_client(memory):
"""Create an async test client for the FastAPI app."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture
def test_bank_id():
"""Provide a unique bank ID for this test run."""
return f"test_reflections_{uuid.uuid4().hex[:8]}"
class TestReflectionsCRUD:
"""Test reflections CRUD operations via memory engine."""
@pytest.mark.asyncio
async def test_create_and_get_reflection(self, memory: MemoryEngine, request_context):
"""Test creating and retrieving a reflection."""
bank_id = f"test-reflection-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="Team Preferences",
source_query="What are the team's communication preferences?",
content="The team prefers async communication via Slack",
tags=["team"],
request_context=request_context,
)
assert reflection["name"] == "Team Preferences"
assert reflection["source_query"] == "What are the team's communication preferences?"
assert reflection["content"] == "The team prefers async communication via Slack"
assert reflection["tags"] == ["team"]
assert "id" in reflection
# Get the reflection
fetched = await memory.get_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
request_context=request_context,
)
assert fetched["id"] == reflection["id"]
assert fetched["name"] == "Team Preferences"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_reflections(self, memory: MemoryEngine, request_context):
"""Test listing reflections with filters."""
bank_id = f"test-reflection-list-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create multiple reflections
await memory.create_reflection(
bank_id=bank_id,
name="Reflection 1",
source_query="Query 1",
content="Content 1",
tags=["tag1"],
request_context=request_context,
)
await memory.create_reflection(
bank_id=bank_id,
name="Reflection 2",
source_query="Query 2",
content="Content 2",
tags=["tag2"],
request_context=request_context,
)
# List all
all_reflections = await memory.list_reflections(
bank_id=bank_id,
request_context=request_context,
)
assert len(all_reflections) == 2
# List with tag filter
tag1_reflections = await memory.list_reflections(
bank_id=bank_id,
tags=["tag1"],
request_context=request_context,
)
assert len(tag1_reflections) == 1
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_reflection(self, memory: MemoryEngine, request_context):
"""Test updating a reflection."""
bank_id = f"test-reflection-update-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="Original Name",
source_query="Original Query",
content="Original Content",
request_context=request_context,
)
# Update the reflection
updated = await memory.update_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
name="Updated Name",
content="Updated Content",
request_context=request_context,
)
assert updated["name"] == "Updated Name"
assert updated["content"] == "Updated Content"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delete_reflection(self, memory: MemoryEngine, request_context):
"""Test deleting a reflection."""
bank_id = f"test-reflection-delete-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="To Delete",
source_query="Query",
content="Content",
request_context=request_context,
)
# Delete the reflection
await memory.delete_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
request_context=request_context,
)
# Verify deletion - should return None
fetched = await memory.get_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
request_context=request_context,
)
assert fetched is None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelsAPI:
"""Test mental models API endpoints.
NOTE: Mental models are now stored in memory_units with fact_type='mental_model'
and accessed via recall with fact_type=["mental_model"]. The old /mental-models
endpoint was removed. These tests are skipped.
"""
@pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']")
@pytest.mark.asyncio
async def test_list_mental_models_empty(self, api_client, test_bank_id):
"""Test listing mental models when none exist."""
pass
@pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']")
@pytest.mark.asyncio
async def test_get_mental_model_not_found(self, api_client, test_bank_id):
"""Test getting a non-existent mental model."""
pass
class TestReflectionsAPI:
"""Test reflections API endpoints."""
@pytest.mark.asyncio
async def test_reflections_api_crud(self, api_client, test_bank_id):
"""Test full CRUD cycle through API."""
import asyncio
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Create a reflection (async operation)
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/reflections",
json={
"name": "API Test Reflection",
"source_query": "What is the API test about?",
"content": "This is an API test reflection",
"tags": ["api-test"],
},
)
assert response.status_code == 200
create_result = response.json()
assert "operation_id" in create_result
operation_id = create_result["operation_id"]
# Wait for the async operation to complete
for _ in range(30): # Wait up to 30 seconds
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{operation_id}")
if response.status_code == 200:
op_status = response.json()
if op_status.get("status") == "completed":
break
await asyncio.sleep(1)
# List reflections to get the created reflection
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections")
assert response.status_code == 200
reflections = response.json()["items"]
assert len(reflections) >= 1
# Find our reflection
reflection = next((r for r in reflections if r["name"] == "API Test Reflection"), None)
assert reflection is not None, f"Reflection not found. Items: {reflections}"
reflection_id = reflection["id"]
# Get the reflection
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
assert response.status_code == 200
assert response.json()["name"] == "API Test Reflection"
# Update the reflection
response = await api_client.patch(
f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}",
json={"name": "Updated API Test Reflection"},
)
assert response.status_code == 200
assert response.json()["name"] == "Updated API Test Reflection"
# Delete the reflection
response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
assert response.status_code == 200
# Verify deletion
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
assert response.status_code == 404
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
class TestRecallWithMentalModelsAndReflections:
"""Test recall integration with mental models and reflections."""
@pytest.mark.asyncio
async def test_recall_includes_mental_models(self, api_client, test_bank_id):
"""Test that recall can include mental models in the response."""
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Note: Mental models are auto-created via consolidation, not manually
# This test just verifies the include parameter works
# Recall with mental models included
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "What is machine learning?",
"include": {
"mental_models": {"max_results": 5},
},
},
)
assert response.status_code == 200
result = response.json()
# Should have mental_models field in response (may be empty)
assert "mental_models" in result or result.get("mental_models") is None
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_recall_includes_reflections(self, api_client, test_bank_id):
"""Test that recall can include reflections in the response."""
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Create a reflection first
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/reflections",
json={
"name": "AI Overview",
"source_query": "What is AI?",
"content": "Artificial intelligence is the simulation of human intelligence",
"tags": [],
},
)
assert response.status_code == 200
# Recall with reflections included
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "What is artificial intelligence?",
"include": {
"reflections": {"max_results": 5},
},
},
)
assert response.status_code == 200
result = response.json()
# Should have reflections in response (may be empty if embedding not generated yet)
assert "reflections" in result or result.get("reflections") is None
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_recall_without_mental_models_by_default(self, api_client, test_bank_id):
"""Test that recall does not include mental models by default."""
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Recall without specifying mental models
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "Test query",
},
)
assert response.status_code == 200
result = response.json()
# Mental models should not be in response
assert result.get("mental_models") is None
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")

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