Compare commits

...
47 Commits
Author SHA1 Message Date
Nicolò Boschi 49aab844ec fix: include tiktoken in slim image 2026-02-10 17:15:29 +01:00
Nicolò Boschi dd1e0986a1 feat: add docs skill (#335)
* feat: add docs skill

* feat: add docs skill
2026-02-10 14:41:51 +01:00
Nicolò Boschi 69dec8ec34 feat: add otel traceability (#330)
* feat: add comprehensive OpenTelemetry tracing

- Add tool execution spans for reflect operations
- Add tool call information (names, params) to spans
- Change verification scope from 'test' to 'verification'
- Add hindsight.reflect_generation span for done() processing
- Implement no-op tracer for improved code readability
- Update documentation for OTEL configuration
- Resolve merge conflicts from rebase

* fix: properly serialize Pydantic models in span recording

- Add _serialize_for_span() helper to handle Pydantic models
- Update all providers to use the helper function
- Fixes test failures with 'Object of type X is not JSON serializable'

* feat: add Grafana LGTM stack for unified local observability

Add Grafana LGTM (Loki, Grafana, Tempo, Mimir) as the recommended
local development observability stack. This provides traces, metrics,
and logs in a single Docker container instead of separate tools.

Changes:
- Add scripts/dev/grafana/ with docker-compose and README
- Add scripts/dev/start-grafana.sh startup script
- Update .env.example to reference Grafana LGTM
- Update configuration docs to emphasize Grafana LGTM as primary option
- Reorder OTLP backend list to show Grafana LGTM first

Benefits:
- Single container vs multiple separate tools (Jaeger, SigNoz, etc.)
- ~515MB image with full observability stack
- Compatible with existing OTLP configuration
- Simpler local development setup

* chore: remove SigNoz scripts and references

Remove SigNoz observability stack in favor of Grafana LGTM as the
sole recommended local development tracing solution.

Changes:
- Delete scripts/dev/signoz/ directory and all SigNoz configurations
- Delete scripts/dev/start-signoz.sh startup script
- Remove SigNoz references from .env.example
- Remove SigNoz from OTLP backends list in configuration docs

Grafana LGTM provides the same capabilities (traces, metrics, logs)
in a simpler single-container setup.

* feat: add consolidation span hierarchy for tracing

Add parent-child span structure for consolidation operations:
- hindsight.consolidation: Parent span for each memory being processed
- hindsight.consolidation_recall: Child span for finding related observations
- LLM call span: Automatically created by LLM provider (scope="consolidation")

This enables detailed timing breakdown in Grafana Tempo:
- Total consolidation time per memory
- Time spent in recall
- Time spent in LLM call
- Time spent executing actions (create/update)

All consolidation tests pass (31/31).

* feat: add Prometheus metrics and GenAI dashboard to Grafana stack

Add comprehensive metrics and dashboarding to the Grafana LGTM stack:

Metrics Collection:
- Configure Prometheus to scrape Hindsight API /metrics endpoint
- Scrape interval: 10 seconds
- Targets hindsight-api on host.docker.internal:8888

GenAI Dashboard:
- Pre-configured dashboard with 6 panels:
  - LLM call rate (by provider/model)
  - LLM call duration (p50/p95 by scope)
  - Token usage - input tokens/sec by scope
  - Token usage - output tokens/sec by scope
  - Operations rate (retain/recall/reflect/consolidation)
  - Operation duration p95 by operation type

Configuration:
- Mount prometheus.yml for metrics scraping
- Mount dashboards directory for auto-provisioning
- Add host.docker.internal mapping for container->host access
- Dashboard provisioning with auto-reload every 10s

Documentation:
- Updated README with metrics viewing instructions
- Added PromQL query examples
- Documented dashboard access and navigation

This provides full observability: traces (Tempo) + metrics (Prometheus/Mimir) + dashboards (Grafana)

* refactor: merge Grafana setup into existing monitoring stack

Consolidate the separate scripts/dev/grafana/ setup into the existing
scripts/dev/monitoring/ stack, using Grafana LGTM (Loki, Grafana, Tempo, Mimir).

Changes:
- Remove separate scripts/dev/grafana/ directory and start-grafana.sh
- Rewrite scripts/dev/monitoring/start.sh to use Docker + Grafana LGTM
  (was: download native Prometheus/Grafana binaries)
- Add docker-compose.yaml for Grafana LGTM container
- Add prometheus.yml for scraping Hindsight API metrics
- Mount existing dashboards from monitoring/grafana/dashboards/
- Add comprehensive README.md

Benefits:
- Single unified monitoring command: ./scripts/dev/start-monitoring.sh
- Uses existing dashboard files (hindsight-operations, hindsight-llm, hindsight-api-service)
- Simpler setup: Docker-based vs downloading/running native binaries
- Full observability: traces + metrics + logs + dashboards in one container
- Standard ports: Grafana on 3000, OTLP on 4317/4318

Architecture:
- Grafana LGTM container (~515MB) provides all components
- Dashboards auto-provisioned from monitoring/grafana/dashboards/
- Prometheus scrapes host.docker.internal:8888/metrics
- Shared hindsight-network for future service-to-service tracing

* fix: run monitoring stack in foreground for easy Ctrl+C stop

Change docker-compose from detached (-d) to foreground mode.
Users can now stop the stack with Ctrl+C instead of needing
to run docker-compose down separately.

* fix: remove invalid home dashboard path and obsolete version field

- Remove GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH environment variable
  (was pointing to wrong path causing 'Failed to load home dashboard' error)
- Remove obsolete 'version' field from docker-compose.yaml
  (docker-compose v2+ doesn't require version field)

* fix: load Hindsight dashboards in Grafana LGTM

Mount Hindsight dashboard JSON files and custom provisioning config
to make dashboards visible in Grafana.

Changes:
- Mount hindsight-operations.json, hindsight-llm.json, hindsight-api-service.json to /otel-lgtm/
- Create grafana-dashboards.yaml with all dashboard providers (default + Hindsight)
- Mount custom provisioning config to override LGTM default

All 3 Hindsight dashboards now appear in Grafana UI with metrics
from Prometheus scraping the Hindsight API /metrics endpoint.

* fix: configure Prometheus to scrape Hindsight API metrics

Update prometheus.yml to include both OTLP receiver config (from LGTM)
and scrape_configs for pulling metrics from Hindsight API.

Changes:
- Mount prometheus.yml to /otel-lgtm/prometheus.yaml (where LGTM reads it)
- Add scrape_configs section to pull from host.docker.internal:8888/metrics
- Keep OTLP receiver configuration for trace metrics
- Set scrape_interval to 5s

Verified: Prometheus now successfully scrapes hindsight_llm_calls_total
and other Hindsight metrics. Dashboards now show live data!

* feat: add comprehensive tracing for recall and improve reflect/mental_model_refresh spans

- Add recall operation tracing with parent-child span hierarchy
  - Parent: hindsight.recall with attributes (bank_id, query, fact_types, etc.)
  - Children: recall_embedding, recall_retrieval, recall_fusion, recall_rerank
  - Fixed context propagation using start_as_current_span()

- Improve reflect tracing spans
  - Remove reflect_generation spans, use reflect instead
  - Change done() tool processing to hindsight.reflect_tool_call

- Fix mental_model_refresh span nesting
  - Add _skip_span parameter to reflect_async to avoid duplicate hindsight.reflect spans
  - Mental model refresh now has clean span hierarchy without nested reflect parent

- Add comprehensive tracing verification tests
  - Test span hierarchy and attributes for all operations
  - Verify parent-child relationships
  - 5 passing tests covering recall, reflect, consolidation, and mental_model_refresh

* refactor: remove redundant is_tracing_enabled() checks

- Remove all is_tracing_enabled() conditional checks before tracing calls
- NoOpTracer/NoOpSpan handle disabled tracing automatically
- Simplify code by always calling tracer methods directly
- Fix NoOpTracer.start_as_current_span() to yield NoOpSpan instead of None

Changes:
- memory_engine.py: Remove 5 is_tracing_enabled checks in recall spans
- agent.py: Remove 2 is_tracing_enabled checks in reflect tool spans
- tracing.py: Fix NoOpTracer context manager to yield proper NoOpSpan

This eliminates ~50 lines of redundant conditional code while maintaining
identical behavior.

* docs: simplify distributed tracing section in monitoring.md

- Make tracing documentation more concise
- Focus on span hierarchy and attributes
- Remove verbose troubleshooting and performance sections
- Keep configuration.md for env vars only
2026-02-10 12:20:48 +01:00
DK09876andClaude Opus 4.6 888b50de12 Fix MCP operations not tracked for usage metering (#334)
MCP middleware was discarding tenant_id and api_key_id after authentication.
The authenticate_mcp() call mutated a RequestContext with these fields, but
tools later created a fresh RequestContext without them. This caused
UsageMeteringValidator to see tenant_id="unknown" and skip billing entirely.

Propagate tenant_id and api_key_id via ContextVars (same pattern as bank_id
and api_key) so the RequestContext passed to the memory engine has the full
auth context needed for usage tracking.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-10 09:38:15 +01:00
Dewaldt HuysamenandClaude Opus 4.6 fb7be3eced feat(openclaw): add excludeProviders config to skip recall/retain for specific providers (#332)
Adds an `excludeProviders` option to the OpenClaw plugin config that allows
users to specify message providers (e.g. 'telegram', 'discord') to exclude
from Hindsight memory recall and retention.

Closes #331

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-09 21:28:29 +01:00
Chris Latimer 4499254f6d Memory conflict blog post 2026-02-09 11:18:30 -07:00
Anatolii Lapytskyi 9943957fb7 feat(helm): add PDB and per-component affinity support (#327)
Add PodDisruptionBudget templates for api, control plane, and worker
(disabled by default). Support per-component affinity overrides with
backward-compatible global affinity fallback.
2026-02-09 18:03:37 +01:00
Nicolò Boschi 03f47e29c8 fix(helm): gke overriding HINDSIGHT_API_PORT (#328) 2026-02-09 17:59:14 +01:00
Nicolò Boschi 1240b82629 0.4.10 changelog 2026-02-09 12:08:47 +01:00
Nicolò Boschi 08f1cda3bf Release v0.4.10
- Update version to 0.4.10 in all components
- Regenerate OpenAPI spec and client SDKs
- 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
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Helm chart
- Sync documentation to version-0.4
2026-02-09 11:44:20 +01:00
Nicolò Boschi a3a9d7b37d doc: prepare doc for 0.4.10 (#325)
* doc: prepare doc for 0.4.10

* fixe

* ci
2026-02-09 11:42:37 +01:00
Nicolò Boschi c2607d7699 fix(helm): improve appVersion usage (#326) 2026-02-09 11:35:08 +01:00
Jerry HenleyandClaude Opus 4.5 e99ee0f243 Add Supabase tenant extension as built-in (#267)
Move the Supabase tenant extension into the hindsight-api package so users
can enable it with just an environment variable — no file copying or Docker
image modifications needed.

Key improvements over the original submission:
- JWKS-based local JWT verification (no network call per request) with
  automatic fallback to /auth/v1/user for legacy HS256 projects
- Service key is now optional (only needed for HS256 or health checks)
- UUID validation on user IDs before schema name construction
- Schema prefix validation against Postgres identifier rules
- Key rotation handling with automatic JWKS cache refresh
- Proper logging via Python logging module
- Tenant extension lifecycle hooks (on_startup/on_shutdown) wired into
  the server lifespan
- Public tenant_extension property on MemoryEngine
- 54 unit tests covering both verification modes, cache behavior, error
  paths, and the extension loader
- README updated to reflect JWKS-first architecture

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-09 10:16:47 +01:00
Van Vuong Ngo c568094b8c fix: do not log db user/password (#312)
* fix: security vulnerability - exposed sensitve database credentials in logs

* add comment

* fix: mask credentials of the postgeSQL connection string
2026-02-09 10:15:03 +01:00
Van Vuong Ngo 5179d5f77d feat: add docker-compose example (#313)
* feat: add docker-compose example

* fix T&V

* doc: add how to quick start hindsight with docker-compose

* chore: fix typo
2026-02-09 10:14:21 +01:00
Anton EvseevandClaude Opus 4.6 981cf6057f fix(openclaw): prevent memory wipe on every session (#323)
Use unique document_id per conversation (sessionKey + timestamp) instead
of static sessionKey. The backend CASCADE-deletes old memories when the
same document_id is reused, causing all prior facts to be lost.

Also:
- Universal envelope stripping for all channels (was Telegram-only)
- Prefer rawMessage over prompt for cleaner recall queries
- Increase recall max_tokens from 512 to 2048

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-09 10:13:19 +01:00
Nicolò Boschi d90588b3e1 feat: improve mcp tools based on endpoint (#318)
* feat: improve mcp tools based on endpoint

* feat: improve mcp tools based on endpoint

* test: add integration test for MCP endpoint routing

- Add test_mcp_endpoint_routing.py to verify single-bank vs multi-bank tool exposure
- Verifies /mcp/ exposes all tools with bank_id parameters
- Verifies /mcp/{bank_id}/ only exposes scoped tools without bank_id parameters
- Regression test for issue #317

Related: #317, #318

* test: use StreamableHTTP client for MCP endpoint routing test

Replace httpx AsyncClient SSE parsing with proper MCP StreamableHTTP
client. This correctly tests the MCP server using the actual protocol
that clients will use.

Fixes #317
2026-02-08 09:28:59 +01:00
Van Vuong Ngo d0f67c9f8b doc: improve Node.js client example (#320)
Fix doc to increase the developer experience...

- if the code is intended to be a CommonJS by using `require` then you have to wrap `await` calls in an async function
- calling `client.recall` with using the results
2026-02-07 10:02:41 +01:00
DK09876andClaude Opus 4.5 fedfb494ee feat: add TenantExtension auth to MCP endpoint (#286)
* feat: add TenantExtension auth to MCP endpoint

Replace static MCP_AUTH_TOKEN check with TenantExtension authentication,
making MCP use the same auth path as REST API.

- MCPMiddleware now calls tenant_extension.authenticate()
- Sets _current_schema from TenantContext for multi-tenant isolation
- Returns 401 on AuthenticationError (same as REST API)
- DefaultTenantExtension: no auth (local dev)
- ApiKeyTenantExtension: validates against env var
- CloudTenantExtension: HMAC + DB lookup (production)

Adds tests for middleware auth rejection, acceptance, and schema routing.

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

* Address PR review: backwards compatibility for MCP auth

- Keep MCP_AUTH_TOKEN env var for legacy MCP servers
- Add authenticate_mcp() method to TenantExtension base class
  - Default implementation calls authenticate()
  - Extensions can override to opt-out of MCP auth
- Add mcp_auth_disabled config option to ApiKeyTenantExtension
  - Set HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true to skip MCP auth
- Remove CloudTenantExtension from public docstring
- Add tests for legacy auth token and mcp_auth_disabled flag
- Update MCP docs with new auth configuration

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

* Add search_docs MCP tool for documentation search

Implements a new MCP tool that searches Hindsight documentation using
Vectorize RAG pipelines. The tool supports:
- Searching core (OSS) docs, cloud docs, or both
- Configurable number of results (1-10)
- Returns ranked results with URLs, similarity scores, and text snippets

New environment variables:
- HINDSIGHT_API_VECTORIZE_ORG_ID
- HINDSIGHT_API_VECTORIZE_API_TOKEN
- HINDSIGHT_API_VECTORIZE_CORE_PIPELINE_ID
- HINDSIGHT_API_VECTORIZE_CLOUD_PIPELINE_ID
- HINDSIGHT_API_VECTORIZE_API_BASE_URL

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

* Add documentation for search_docs MCP tool

- Add Vectorize environment variables to configuration.md
- Add search_docs tool to MCP server available tools
- Add reflect tool documentation (was missing)

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

* Add tests for search_docs MCP tool

Tests cover:
- DocsSource enum values and parsing
- _clean_text HTML stripping helper
- _search_vectorize_pipeline with mocked httpx
- Tool registration and function execution
- Source filtering (core/cloud/all)
- Result sorting by similarity
- Error handling for pipeline failures
- HTML cleaning in results
- Invalid source defaulting to 'all'

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

* Move search_docs to hindsight-cloud, add MCPExtension pattern

- Add MCPExtension base class for registering additional MCP tools
- Load MCPExtension in create_mcp_server when configured
- Remove search_docs tool (moved to hindsight-cloud CloudMCPExtension)
- Remove Vectorize config from hindsight-core
- Add tests for MCPExtension pattern
- Update docs to remove search_docs references

The MCPExtension pattern allows cloud (or any extension package) to
register additional MCP tools via:
  HINDSIGHT_API_MCP_EXTENSION=package.module:ExtensionClass

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

* Address PR review feedback

- Remove CloudTenantExtension mention from MCPMiddleware docstring
- Fix docs: clarify that ApiKeyTenantExtension must be explicitly enabled
- Revert changes to versioned docs (0.3 and 0.4) - synced automatically on release

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

* Format mcp.py line length

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

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-06 12:28:05 -07:00
Nicolò Boschi 0430588e32 fix: hindsight-embed profiles are not loaded correctly (#316)
* fix: hindsight-embed profiles are not loaded correctly

* fix: hindsight-embed profiles are not loaded correctly
2026-02-06 17:13:54 +01:00
Nicolò Boschi 2af0e08dba doc: update claude-code usage terms (#315)
* doc: update claude-code usage terms

* doc: update claude-code usage terms

* doc: update claude-code usage terms
2026-02-06 16:53:12 +01:00
Nicolò Boschi f64817814a feat: slim docker distro (#314)
* feat: slim docker distro

* feat: slim docker distro

* push
2026-02-06 15:00:24 +01:00
Nicolò Boschi fa4cbf7ef2 fix(ci): resolve flaky test failures in api tests (#311)
* fix: resolve flaky test failures in api tests

Fixed 4 critical test failures that revealed real production issues:

1. test_sensory_dimension_preservation: Updated fact extraction prompt to
   clarify that sensory/emotional details ARE important to remember even if
   they seem small. The "6 months" filter was too aggressive and causing LLM
   to skip valid observations.

2. test_llm_provider_api_methods[openai-gpt-5]: Increased max_completion_tokens
   from 200 to 500 for tool calling tests. Non-nano models like gpt-5 were
   hitting token limits before completing tool calls.

3. test_reflect_chinese_content: Added prominent anti-hallucination warnings
   to reflect agent prompts. LLM was making up names (张飞, 张三, 赵信) instead
   of using the actual names from retrieved facts (张伟, 李明). Added explicit
   instructions at the very top of system prompts to NEVER fabricate names and
   to use EXACT names from retrieved data.

4. test_llm_provider_api_methods[groq-openai/gpt-oss-120b]: Skipped this model
   in tests as it consistently times out (>120s) due to slow Groq API responses.

All changes address real production code issues, not test flakiness.

* refactor: simplify anti-hallucination prompts and document groq issue

- Removed verbose anti-hallucination section with emojis/borders
- Moved core anti-hallucination rules to top of system prompts in clean format
- Kept essential rules: NEVER make up names/entities, ONLY use tool results
- Removed language override rule (directives can control language)
- Removed specific example (too prescriptive)

Groq gpt-oss-120b:
- Documented that API hangs on receive_response_body (Groq API bug)
- Skip is justified: headers received successfully but body never arrives
- This is gpt-oss-120b specific, not a general Groq provider issue

* fix: remove groq skip as requested

- Groq gpt-oss-120b may be slow but should not be skipped
- test_extensions.py::test_reflect_pre_hook_receives_all_parameters passes locally (50s)
- CI timeout appears to be from LLM producing malformed tool names (done<|channel|>commentary)
  which triggers retries and slows down the test

* fix: ensure unique timestamps for facts across different documents

The time offset logic was resetting to 0 for each new content_index, causing
all facts from different documents/conversations to have the same base timestamp
even when they should be distinguishable.

Changed to use absolute position (i) instead of relative position (i - content_fact_start)
so that:
- Content 0, Fact 0: offset = 0s
- Content 0, Fact 1: offset = 10s
- Content 1, Fact 0: offset = 20s (now unique!)
- Content 1, Fact 1: offset = 30s

This ensures facts from different batch-retained documents have unique timestamps
for proper temporal ordering in retrieval.

Fixes test_fact_ordering.py::test_multiple_documents_ordering

* fix: increase timeout for test_llm_provider_api_methods to 300s

The groq gpt-oss-120b model can be very slow (API hangs on response body),
taking >120s to complete. Increased timeout to 300s to prevent CI flakiness
while still catching real hangs.

This affects all provider/model combinations in the test, not just Groq,
but most complete in <30s so the increased timeout won't affect them.

* fix: skip structured output for groq gpt-oss-120b, reinforce date extraction

1. Groq gpt-oss-120b doesn't support response_format (structured output)
   - Returns 400 'json_validate_failed' error
   - Retries with exponential backoff caused 300s timeout
   - Skip test #3 (structured output) for this model

2. Reinforce date extraction prompt
   - Add CRITICAL instruction to extract absolute dates like 'March 15, 2024'
   - Helps prevent flaky test_extract_facts_with_absolute_dates failures
2026-02-06 13:56:59 +01:00
Nicolò Boschi 2109397028 ci: ensure python 3.14 compatibility (#310) 2026-02-06 10:50:45 +01:00
Nicolò Boschi c4ef090a20 feat: support markdown in reflect and mental models (#307)
* feat: support markdown in reflect and mental models

* chore: regenerate clients and OpenAPI spec with markdown field descriptions
2026-02-06 10:49:13 +01:00
Dewaldt Huysamen 96f487213c fix(openclaw): remove format:uri to fix ajv warning (#309)
Remove `format: "uri"` from hindsightApiUrl schema property.

OpenClaw's schema validator uses Ajv without ajv-formats loaded, causing:
  unknown format "uri" ignored in schema at path "#/properties/hindsightApiUrl"

The URI validation isn't critical since invalid URLs will fail at connection time.
This removes the warning without affecting functionality.
2026-02-06 10:45:43 +01:00
Nicolò Boschi 0d8d805832 ci: ensure backwards/forward compatibility of the API (#306) 2026-02-05 18:43:05 +01:00
Nicolò Boschi 1cd836229b 0.4.9 changelog 2026-02-05 17:11:52 +01:00
Nicolò Boschi 90ad003c46 docs: add AI SDK integration documentation (#304)
* docs: add AI SDK integration documentation

- Add comprehensive AI SDK documentation in docs/sdks/integrations/ai-sdk.md
  - Detailed description of all three memory tools (retain, recall, reflect)
  - Complete parameter documentation and return types
  - Advanced usage patterns (streaming, multi-user, ToolLoopAgent)
  - HTTP client example for zero-dependency usage
  - TypeScript types and API reference
  - Best practices and system prompt examples

- Update AI SDK README to brief quickstart with link to docs
  - Single source of truth: comprehensive docs in documentation site
  - README now focuses on quick setup and points to full docs
  - Maintains features list and basic example for npm page

* fix
2026-02-05 17:05:18 +01:00
Nicolò Boschi 278718dd84 fix: tagged directives should be applied to tagged mental models (#303)
* fix: tagged directives should be applied to tagged mental models

* test: add unit test for based_on structure

Verify that reflect returns the correct based_on structure with:
- directives as dicts (id, name, content) in based_on.directives
- mental models as MemoryFact objects in based_on.mental-models
- memories separated properly

This ensures directives and mental models are not mixed together
in the API response.
2026-02-05 13:22:56 +01:00
Hayden Rear 093ecff48d fixed cast error (#300)
Signed-off-by: hayden.rear <[email protected]>
2026-02-05 09:05:26 +01:00
Nicolò Boschi 85b9074f43 Release v0.4.9
- Update version to 0.4.9 in all components
- Regenerate OpenAPI spec and client SDKs
- 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
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Helm chart
- Sync documentation to version-0.4
2026-02-04 20:27:05 +01:00
Nicolò Boschi 7e339e1677 feat: ai sdk integration (#299)
* feat: ai sdk integration

* more fixes

* fix(security): mental model refresh tag-based security

- Mental model refresh now passes tags with all_strict matching
- Consolidation only triggers refresh for mental models with matching tags
- Consolidation filters related observations by tags (all_strict)
- Added tests to verify tag-based security boundaries
- Updated OpenAPI spec to include tags and text_preview in list_documents
- Added tags column to documents UI table

* chore: regenerate OpenAPI spec after rebase

* fix: improve consolidation prompt for contradiction handling and mental model refresh security

- Enhanced consolidation prompt to be more explicit about capturing temporal changes in contradictions
- Fixed mental model refresh security: tagged memories now only trigger refresh of mental models with matching tags
- Added stricter tag filtering to prevent cross-scope mental model refreshes

Fixes test_consolidation_merges_contradictions by improving LLM instructions to use temporal markers like "used to X, now Y" when merging contradictory facts.

Note: test_refresh_with_tags_only_accesses_same_tagged_models still needs investigation - REFLECT operation may need additional tag filtering.

* fix: mental model refresh security - proper tag filtering in search

Fixed tool_search_mental_models to properly handle all_strict tag matching mode by using the centralized build_tags_where_clause function. Previously, the function only handled "all" vs "any" modes and always included untagged mental models when using non-"all" modes.

This ensures that when a tagged mental model is refreshed with all_strict matching, it cannot access untagged mental models, preventing cross-scope information leakage.

Fixes test_refresh_with_tags_only_accesses_same_tagged_models.

Note: test_sensory_dimension_preservation is failing but this is a pre-existing issue on main branch - the LLM model (gpt-oss-20b) is not extracting facts from sensory text. Not related to security changes.

* chore: apply formatting from pre-commit hook

* fix: allow untagged mental models to be refreshed by any consolidation

Untagged mental models are considered "global" and should be refreshed
by any consolidation, regardless of whether tagged or untagged memories
were consolidated. This maintains security boundaries while allowing
global mental models to stay fresh.

When tagged memories are consolidated:
- Refresh mental models with matching tags (security boundary)
- Also refresh untagged mental models (they're global)
- DO NOT refresh mental models with different tags

When untagged memories are consolidated:
- Only refresh untagged mental models
- DO NOT refresh tagged mental models (security boundary)

Fixes test_consolidation_only_refreshes_matching_tagged_models.
2026-02-04 20:25:59 +01:00
Chris Bartholomew dd621a69d0 Fix recall endpoint timeout handling and add query length validation (#298)
- Add MAX_QUERY_TOKENS (500) limit to prevent expensive operations on oversized queries
- Return 400 error with clear message when query exceeds token limit
- Add specific handling for TimeoutError to return 504 Gateway Timeout instead of 500
- Improves error messages for timeout scenarios
2026-02-04 17:26:25 +01:00
Nicolò Boschi 7097716204 feat: improve mental models ux on control plane (#297)
* feat: improve mental models ux on control plane

* feat: improve mental models ux on control plane

* gen

* feat(cli): add --id flag to mental model create command

* fix(cli): revert unused variable underscore prefix that breaks compilation

The underscore prefix on stdout/stderr variables was added to suppress
warnings, but these variables are actually used in assert messages,
causing compilation errors. Reverting to original names.
2026-02-04 15:49:03 +01:00
Nicolò Boschi d3302c95b9 feat: HindsightEmbedded python SDK (#293)
* feat: HindsightEmbedded python SDK

* feat: HindsightEmbedded python SDK

* fixes

* improve

* ci

* improvemnts

* fix test

* fix test

* fix: update tests to use Pydantic model attributes instead of dict access

- Fixed test_server_integration.py to access Pydantic model attributes directly
- Changed dict-style access (response["field"]) to attribute access (response.field)
- Fixed .get() calls on Pydantic models
- Updated recall() calls to access .results attribute
- Updated reflect() calls to access .text attribute
- Fixed test_list_banks to use namespace API instead of deleted default_api
- Fixed attribute shadowing in HindsightClient wrapper (renamed _*_api to _*_namespace)

* fix: add list() method to BanksAPI namespace

* fix: remove leftover async cleanup code from test_list_banks

* docs: remove Advanced Configuration section from embed.md
2026-02-04 14:41:19 +01:00
Nicolò Boschi 665877bb01 feat(hindsight-litellm): support streaming on wrappers (#296) 2026-02-04 13:59:29 +01:00
Nicolò Boschi a43d208e93 fix: improve claude code and codex for /reflect (#285)
* fix: improve mental models response

* fix: improve mental models response

* fix

* improvemnts

* fix test
2026-02-04 13:34:45 +01:00
Nicolò Boschi 34d9188e13 fix: hide hf logging (#295) 2026-02-04 13:12:30 +01:00
Anton EvseevandClaude Opus 4.5 9a776e9f58 feat(openclaw): add dynamic per-channel memory banks (#290)
Add support for per-channel memory isolation in OpenClaw plugin.
Each channel (Slack, Telegram, Discord, etc.) gets its own memory bank,
preventing memory leakage between channels.

Changes:
- Add deriveBankId() to create channel-specific bank IDs
- Bank ID format: {messageProvider}-{channelId} (e.g., slack-C123)
- Add getClientForContext() for context-aware client access
- Update hook handlers to (event, ctx) signature
- Set bank mission on first use per dynamic bank
- Add dynamicBankId and bankIdPrefix config options

Configuration:
- dynamicBankId: true (default) enables per-channel isolation
- bankIdPrefix: optional prefix for namespacing (e.g., "prod")

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-04 11:38:14 +01:00
Anton Evseev d02affd8f2 docs: expand external API configuration section for OpenClaw (#294)
- Add plugin configuration example with hindsightApiUrl and hindsightApiToken
- Document behavior differences when using external API mode
- Add verification steps and log messages to expect
- Explain use cases (shared memory, production, team environments)
2026-02-04 11:23:30 +01:00
Anton Evseev 6b346925e2 feat(openclaw): add external Hindsight API support (#289)
Add support for connecting to an external Hindsight API instead of
starting a local daemon. This enables:
- Shared memory across multiple OpenClaw instances
- Centralized Hindsight deployment (e.g., on GKE)
- Reduced resource usage (no local daemon per instance)

Configuration:
- HINDSIGHT_EMBED_API_URL env var or hindsightApiUrl in plugin config
- HINDSIGHT_EMBED_API_TOKEN env var or hindsightApiToken for auth

When external API is configured:
- Skip local daemon startup
- Health check external API on startup
- Pass API URL/token to CLI commands via env vars

Falls back to local daemon mode when not configured.
2026-02-04 10:24:33 +01:00
Anton Evseev 63e2964a4c fix(openclaw): improve shell argument escaping (#288)
Add comprehensive shell argument escaping using POSIX single-quote method.

Problem:
- Current code only escapes single quotes inline
- Other shell metacharacters ($, `, !, etc.) not explicitly handled
- Document ID in retain() was not escaped

Solution:
- Add exported escapeShellArg() function using POSIX single-quote escaping
- Replace inline escaping with shared function
- Escape document ID in retain()
- Add comprehensive tests (17 test cases) covering all shell-special chars

The POSIX single-quote method handles ALL shell metacharacters by wrapping
in single quotes (which protect everything except single quotes themselves)
and escaping any embedded single quotes with '\'' sequence.
2026-02-04 10:22:52 +01:00
Nicolò Boschi d5403a4b29 doc: update cookbook (#284)
* fix: sync-cookbook now supports new cookbook repo layout

Cookbook repository changed structure:
- Applications moved from root to applications/ subdirectory
- Notebooks remain in notebooks/ directory (unchanged)

Updated sync script to:
- Look for apps in applications/* instead of root/*
- Update GitHub URLs to include applications/ path
- Add safety check if applications/ dir doesn't exist

* doc: update cookbook

* doc: update cookbook

* doc: update cookbook
2026-02-03 15:34:41 +01:00
Nicolò Boschi a24941f83b doc: changelog for 0.4.8 (#283)
* doc: changelog for 0.4.8

* improve docs
2026-02-03 14:04:55 +01:00
Nicolò Boschi 21b25fe8fe Release v0.4.8
- Update version to 0.4.8 in all components
- Regenerate OpenAPI spec and client SDKs
- 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
- OpenClaw integration: hindsight-integrations/openclaw
- Helm chart
- Sync documentation to version-0.4
2026-02-03 13:51:30 +01:00
Nicolò Boschi 794a7435a9 fix: improve embed ux with rich logging and profile isolation (#282)
* fix: improve embed ux with rich logging and profile isolation

* chore: regenerate uv.lock to fix corrupted streamlit RECORD

* test: update database URL assertion for profile-specific pg0

* Revert: restore lint.sh to main branch version
2026-02-03 13:50:28 +01:00
353 changed files with 39561 additions and 4169 deletions
+15
View File
@@ -50,3 +50,18 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# Observability & Tracing (Optional - disabled by default)
# Enable OpenTelemetry tracing for LLM calls (GenAI semantic conventions)
# HINDSIGHT_API_OTEL_TRACES_ENABLED=true
#
# Local development with Grafana LGTM stack (recommended - see scripts/dev/grafana/README.md)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
#
# Cloud backends (Grafana Cloud, Langfuse, DataDog, etc.)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=https://your-backend-url
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-token"
#
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
+82 -2
View File
@@ -188,6 +188,55 @@ jobs:
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/ai-sdk
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/ai-sdk
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ai-sdk-integration
path: hindsight-integrations/ai-sdk/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -291,6 +340,7 @@ jobs:
retention-days: 1
release-docker-images:
name: Release Docker (${{ matrix.image_name }}${{ matrix.tag_suffix }})
runs-on: ubuntu-latest
permissions:
contents: read
@@ -300,10 +350,28 @@ jobs:
include:
- target: api-only
image_name: hindsight-api
tag_suffix: ""
build_args: ""
- target: api-only
image_name: hindsight-api
tag_suffix: "-slim"
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
- target: cp-only
image_name: hindsight-control-plane
tag_suffix: ""
build_args: ""
- target: standalone
image_name: hindsight
tag_suffix: ""
build_args: ""
- target: standalone
image_name: hindsight
tag_suffix: "-slim"
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
steps:
- uses: actions/checkout@v4
@@ -341,6 +409,9 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
flavor: |
latest=auto
suffix=${{ matrix.tag_suffix }}
tags: |
type=semver,pattern={{version}},value=${{ steps.get_version.outputs.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.get_version.outputs.VERSION }}
@@ -366,7 +437,7 @@ jobs:
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
# run: ./docker/test-image.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
# Build multi-platform and push to release tags
- name: Build and push release images
@@ -375,6 +446,7 @@ jobs:
context: .
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
build-args: ${{ matrix.build_args }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
@@ -415,7 +487,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -444,6 +516,12 @@ jobs:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Control Plane
uses: actions/download-artifact@v4
with:
@@ -487,6 +565,8 @@ jobs:
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+158 -40
View File
@@ -9,42 +9,11 @@ concurrency:
cancel-in-progress: true
jobs:
build-python-packages:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: hindsight-all
path: hindsight
- name: hindsight-api
path: hindsight-api
- name: hindsight-client
path: hindsight-clients/python
- name: hindsight-embed
path: hindsight-embed
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build ${{ matrix.name }}
working-directory: ./${{ matrix.path }}
run: uv build
build-api-python-versions:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v4
@@ -105,6 +74,29 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: npm run build
build-ai-sdk-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/ai-sdk
run: npm test
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
build-control-plane:
runs-on: ubuntu-latest
@@ -285,16 +277,35 @@ jobs:
run: helm lint helm/hindsight
build-docker-images:
name: Build Docker (${{ matrix.name }})
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: api-only
name: api
variant: full
build_args: ""
- target: api-only
name: api-slim
variant: slim
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
- target: cp-only
name: control-plane
variant: full
build_args: ""
- target: standalone
name: standalone
variant: full
build_args: ""
- target: standalone
name: standalone-slim
variant: slim
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
steps:
- uses: actions/checkout@v4
@@ -313,20 +324,31 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ${{ matrix.name }} image
- name: Build ${{ matrix.name }} image (${{ matrix.variant }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
build-args: ${{ matrix.build_args }}
push: false
load: false
load: ${{ matrix.variant == 'slim' }}
tags: hindsight-${{ matrix.name }}:test
# Removed GitHub Actions cache (type=gha) - it frequently returns 502 errors
# causing buildx to fail with "failed to parse error response 502"
# Build will be slower but more reliable
# TODO: Re-enable smoke test when disk space issue is resolved
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
# Only test slim variants to save disk space (they're much smaller)
# Slim variants require external embedding providers
- name: Smoke test - verify container starts
if: matrix.variant == 'slim'
env:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_EMBEDDINGS_PROVIDER: openai
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
HINDSIGHT_API_RERANKER_PROVIDER: cohere
HINDSIGHT_API_COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
run: ./docker/test-image.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
test-api:
runs-on: ubuntu-latest
@@ -790,6 +812,54 @@ jobs:
working-directory: ./hindsight-embed
run: ./test.sh
test-hindsight-all:
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
# For test_server_integration.py compatibility
HINDSIGHT_LLM_PROVIDER: groq
HINDSIGHT_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_LLM_MODEL: openai/gpt-oss-20b
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- 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: Build hindsight-all
working-directory: ./hindsight
run: uv build
- name: Install dependencies
working-directory: ./hindsight
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-all-${{ hashFiles('hindsight/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-all-
${{ runner.os }}-huggingface-
- name: Run unit tests
working-directory: ./hindsight
run: uv run pytest tests/ -v
test-doc-examples:
runs-on: ubuntu-latest
needs: test-rust-cli
@@ -1044,4 +1114,52 @@ jobs:
git diff --stat
exit 1
fi
echo "✓ All generated files are up to date"
echo "✓ All generated files are up to date"
check-openapi-compatibility:
runs-on: ubuntu-latest
env:
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full git history to access base branch
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --index-strategy unsafe-best-match
- name: Check OpenAPI compatibility with base branch
run: |
# Get the base branch (usually main)
BASE_BRANCH="${{ github.base_ref }}"
if [ -z "$BASE_BRANCH" ]; then
echo "⚠️ Warning: No base branch found (not a PR?). Skipping compatibility check."
exit 0
fi
echo "Checking OpenAPI compatibility against base branch: $BASE_BRANCH"
# Extract the old OpenAPI spec from base branch
git show "origin/$BASE_BRANCH:hindsight-docs/static/openapi.json" > /tmp/old-openapi.json
if [ ! -s /tmp/old-openapi.json ]; then
echo "⚠️ Warning: Could not find OpenAPI spec in base branch. Skipping compatibility check."
exit 0
fi
# Check compatibility using our tool
cd hindsight-dev
uv run check-openapi-compatibility /tmp/old-openapi.json ../hindsight-docs/static/openapi.json
+3 -1
View File
@@ -53,4 +53,6 @@ hindsight-clients/rust/target
whats-next.md
TASK.md
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
# CHANGELOG.md
blog-post*
+1
View File
@@ -45,6 +45,7 @@ cd hindsight-control-plane && npm run dev
./scripts/dev/start-docs.sh
```
### Generating Clients/OpenAPI
```bash
# Regenerate OpenAPI spec after API changes (REQUIRED after changing endpoints)
+53 -21
View File
@@ -42,27 +42,51 @@ If you need more control over how and when your agent stores and recalls memorie
![Hindsight Banner](./hindsight-docs/static/img/migration-code.png)
---
> 🤖 **Using a coding agent?** Install the Hindsight documentation skill for instant access to docs while you code:
> ```bash
> npx skills add https://github.com/vectorize-io/hindsight --skill hindsight-docs
> ```
> Works with Claude Code, Cursor, and other AI coding assistants.
---
## Quick Start
### Docker (recommended)
```bash
export OPENAI_API_KEY=your-key
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
>API: http://localhost:8888
>UI: http://localhost:9999
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, and `lmstudio`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
API: http://localhost:8888
UI: http://localhost:9999
Install client:
### Docker (external PostgreSQL)
```bash
export OPENAI_API_KEY=sk-xxx
export HINDSIGHT_DB_PASSWORD=choose-a-password
cd docker/docker-compose
docker compose up
```
>API: http://localhost:8888
>UI: http://localhost:9999
### Client
```bash
pip install hindsight-client -U
@@ -70,7 +94,7 @@ pip install hindsight-client -U
npm install @vectorize-io/hindsight-client
```
Python example:
#### Python
```python
from hindsight_client import Hindsight
@@ -87,7 +111,29 @@ client.recall(bank_id="my-bank", query="What does Alice do?")
client.reflect(bank_id="my-bank", query="Tell me about Alice")
```
### Python (embedded, no Docker)
#### Node.js / TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const main = async () => {
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
const results = await client.recall('my-bank', 'What does Alice like?');
console.log(results);
}
main();
```
### Python Embedded (no server required)
```bash
pip install hindsight-all -U
@@ -107,20 +153,6 @@ with HindsightServer(
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
```
### Node.js / TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
await client.recall('my-bank', 'What does Alice like?');
```
---
+54
View File
@@ -0,0 +1,54 @@
# Docker Compose file for Hindsight with PostgreSQL and pgvector
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18)
services:
db:
# Use a PostgreSQL-Image with pgvector extension pre-installed
# see https://hub.docker.com/r/pgvector/pgvector
image: pgvector/pgvector:pg${HINDSIGHT_DB_VERSION:-18}
container_name: hindsight-db
restart: always
# Expose PostgreSQL port
# ports:
# - "5432:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
+53 -2
View File
@@ -8,6 +8,7 @@
# Set to false when using external providers (TEI, OpenAI, Cohere)
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
# Only effective when INCLUDE_LOCAL_MODELS=true
# NOTE: tiktoken encodings are ALWAYS preloaded (required for air-gapped deployments)
#
# Examples:
# docker build -t hindsight . # Both (standalone)
@@ -167,6 +168,28 @@ USER hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
@@ -185,7 +208,6 @@ print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
@@ -208,6 +230,10 @@ ENV HINDSIGHT_API_LOG_LEVEL=info
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=false
ENV PYTHONUNBUFFERED=1
# Suppress verbose transformers/HuggingFace model loading warnings
ENV TRANSFORMERS_VERBOSITY=error
ENV HF_HUB_VERBOSITY=error
ENV TOKENIZERS_PARALLELISM=false
CMD ["/app/start-all.sh"]
@@ -293,6 +319,28 @@ USER hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
@@ -311,7 +359,6 @@ print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
@@ -336,6 +383,10 @@ ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=true
ENV PYTHONUNBUFFERED=1
# Suppress verbose transformers/HuggingFace model loading warnings
ENV TRANSFORMERS_VERBOSITY=error
ENV HF_HUB_VERBOSITY=error
ENV TOKENIZERS_PARALLELISM=false
CMD ["/app/start-all.sh"]
@@ -6,28 +6,40 @@
# Can be run locally or in CI pipelines.
#
# Usage:
# ./scripts/docker-smoke-test.sh <image> [target]
# ./docker/test-image.sh <image> [target]
#
# Arguments:
# image - Docker image to test (e.g., hindsight-api:test, ghcr.io/vectorize-io/hindsight:latest)
# target - Optional: 'cp-only' for control plane, otherwise assumes API image (default: api)
#
# Environment variables:
# GROQ_API_KEY - Required for API/standalone images (LLM verification)
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: groq)
# HINDSIGHT_API_LLM_MODEL - LLM model (default: llama-3.3-70b-versatile)
# SMOKE_TEST_TIMEOUT - Timeout in seconds (default: 120)
# SMOKE_TEST_CONTAINER_NAME - Container name (default: hindsight-smoke-test)
# GROQ_API_KEY - Required for API/standalone images (LLM verification)
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: groq)
# HINDSIGHT_API_LLM_MODEL - LLM model (default: llama-3.3-70b-versatile)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER - Embeddings provider (optional, for slim images: openai, cohere, tei)
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY - OpenAI API key for embeddings (optional)
# HINDSIGHT_API_RERANKER_PROVIDER - Reranker provider (optional, for slim images: cohere, tei)
# HINDSIGHT_API_COHERE_API_KEY - Cohere API key for reranking (optional)
# SMOKE_TEST_TIMEOUT - Timeout in seconds (default: 120)
# SMOKE_TEST_CONTAINER_NAME - Container name (default: hindsight-smoke-test)
#
# Examples:
# # Test a locally built image
# ./scripts/docker-smoke-test.sh hindsight-api:test
# # Test a locally built full image
# ./docker/test-image.sh hindsight-api:test
#
# # Test a released image
# ./scripts/docker-smoke-test.sh ghcr.io/vectorize-io/hindsight:latest
# ./docker/test-image.sh ghcr.io/vectorize-io/hindsight:latest
#
# # Test control plane image
# ./scripts/docker-smoke-test.sh hindsight-control-plane:test cp-only
# ./docker/test-image.sh hindsight-control-plane:test cp-only
#
# # Test slim image with external providers
# export GROQ_API_KEY=gsk_xxx
# export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
# export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxx
# export HINDSIGHT_API_RERANKER_PROVIDER=cohere
# export HINDSIGHT_API_COHERE_API_KEY=xxx
# ./docker/test-image.sh hindsight-slim:test
#
# Exit codes:
# 0 - Success (container healthy)
@@ -108,12 +120,32 @@ if [ "$TARGET" = "cp-only" ]; then
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
"$IMAGE"
else
docker run -d --name "$CONTAINER_NAME" \
-e HINDSIGHT_API_LLM_PROVIDER="$LLM_PROVIDER" \
-e HINDSIGHT_API_LLM_API_KEY="${GROQ_API_KEY}" \
-e HINDSIGHT_API_LLM_MODEL="$LLM_MODEL" \
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
"$IMAGE"
# Build docker run command with required and optional env vars
DOCKER_CMD="docker run -d --name $CONTAINER_NAME"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_PROVIDER=$LLM_PROVIDER"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_API_KEY=${GROQ_API_KEY}"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_MODEL=$LLM_MODEL"
# Add optional embeddings provider config
if [ -n "${HINDSIGHT_API_EMBEDDINGS_PROVIDER:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_PROVIDER=${HINDSIGHT_API_EMBEDDINGS_PROVIDER}"
fi
if [ -n "${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY}"
fi
# Add optional reranker provider config
if [ -n "${HINDSIGHT_API_RERANKER_PROVIDER:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_RERANKER_PROVIDER=${HINDSIGHT_API_RERANKER_PROVIDER}"
fi
if [ -n "${HINDSIGHT_API_COHERE_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_COHERE_API_KEY=${HINDSIGHT_API_COHERE_API_KEY}"
fi
DOCKER_CMD="$DOCKER_CMD -p ${HEALTH_PORT}:${HEALTH_PORT}"
DOCKER_CMD="$DOCKER_CMD $IMAGE"
eval $DOCKER_CMD
fi
# Wait for health endpoint
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
#
# Local Test Script for Slim Docker Images
#
# This script makes it easy to test slim images locally with external providers.
# It expects API keys to be set in environment variables.
#
# Usage:
# export GROQ_API_KEY=gsk_xxx
# export OPENAI_API_KEY=sk-xxx
# export COHERE_API_KEY=xxx
# ./docker/test-slim-local.sh
#
# Or inline:
# GROQ_API_KEY=gsk_xxx OPENAI_API_KEY=sk_xxx COHERE_API_KEY=xxx ./docker/test-slim-local.sh
#
set -euo pipefail
# Check for required API keys
if [ -z "${GROQ_API_KEY:-}" ]; then
echo "❌ Error: GROQ_API_KEY environment variable is required"
echo "Set it with: export GROQ_API_KEY=gsk_xxx"
exit 1
fi
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "❌ Error: OPENAI_API_KEY environment variable is required"
echo "Set it with: export OPENAI_API_KEY=sk-xxx"
exit 1
fi
if [ -z "${COHERE_API_KEY:-}" ]; then
echo "❌ Error: COHERE_API_KEY environment variable is required"
echo "Set it with: export COHERE_API_KEY=xxx"
exit 1
fi
# Configuration
IMAGE="${1:-hindsight-slim:test}"
echo "Testing image: $IMAGE"
echo ""
# Set up external providers
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=$OPENAI_API_KEY
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=$COHERE_API_KEY
# Run the test
exec "$(dirname "$0")/test-image.sh" "$IMAGE" standalone
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.7
appVersion: "0.4.7"
version: 0.4.10
appVersion: "0.4.10"
keywords:
- ai
- memory
+5 -2
View File
@@ -33,7 +33,7 @@ spec:
- name: api
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version }}"
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
ports:
- name: http
@@ -60,6 +60,9 @@ spec:
- name: HINDSIGHT_API_WORKER_ENABLED
value: "false"
{{- end }}
{{- /* Explicitly set port to override K8s service discovery env var (HINDSIGHT_API_PORT) */}}
- name: HINDSIGHT_API_PORT
value: {{ .Values.api.service.targetPort | quote }}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
@@ -84,7 +87,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
{{- with (.Values.api.affinity | default .Values.affinity) }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -33,7 +33,7 @@ spec:
- name: control-plane
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version }}"
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
ports:
- name: http
@@ -71,7 +71,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
{{- with (.Values.controlPlane.affinity | default .Values.affinity) }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
+56
View File
@@ -0,0 +1,56 @@
{{- if and .Values.api.enabled .Values.api.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-api
labels:
{{- include "hindsight.api.labels" . | nindent 4 }}
spec:
{{- if .Values.api.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.api.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.api.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.api.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.api.selectorLabels" . | nindent 6 }}
{{- end }}
---
{{- if and .Values.controlPlane.enabled .Values.controlPlane.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-control-plane
labels:
{{- include "hindsight.controlPlane.labels" . | nindent 4 }}
spec:
{{- if .Values.controlPlane.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.controlPlane.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.controlPlane.selectorLabels" . | nindent 6 }}
{{- end }}
---
{{- if and .Values.worker.enabled .Values.worker.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-worker
labels:
{{- include "hindsight.worker.labels" . | nindent 4 }}
spec:
{{- if .Values.worker.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.worker.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.worker.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.worker.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.worker.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -32,7 +32,7 @@ spec:
- name: worker
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version }}"
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
command: ["hindsight-worker"]
ports:
@@ -99,7 +99,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
{{- with (.Values.worker.affinity | default .Values.affinity) }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
+32 -4
View File
@@ -1,7 +1,8 @@
# Default values for hindsight
# Chart version - use this to set a consistent image tag across all components
version: "0.1.1"
# Global version override - use this to set a consistent image tag across all components
# If not set, defaults to Chart.appVersion from Chart.yaml
# version: ""
# Use an existing secret instead of creating one from values
# When set, all keys from this secret are injected as environment variables via envFrom
@@ -57,6 +58,15 @@ api:
timeoutSeconds: 3
failureThreshold: 3
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Environment variables
env:
#HINDSIGHT_API_LLM_PROVIDER: "groq"
@@ -75,7 +85,7 @@ worker:
image:
repository: ghcr.io/vectorize-io/hindsight-api
pullPolicy: IfNotPresent
# tag defaults to .Values.version if not specified
# tag: "" # defaults to .Values.version, then Chart.appVersion if not specified
service:
# Service for metrics scraping (headless for StatefulSet)
@@ -121,6 +131,15 @@ worker:
# HTTP port for metrics/health (matches service.targetPort)
HINDSIGHT_API_WORKER_HTTP_PORT: "8889"
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Secret environment variables (inherited from api.secrets if not specified)
secrets: {}
@@ -164,6 +183,15 @@ controlPlane:
timeoutSeconds: 3
failureThreshold: 3
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Environment variables
env:
NODE_ENV: "production"
@@ -262,7 +290,7 @@ nodeSelector: {}
# Tolerations
tolerations: []
# Affinity
# Affinity (applied to all components unless overridden per-component)
affinity: {}
# Autoscaling
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.7"
__version__ = "0.4.10"
@@ -0,0 +1,60 @@
"""Fix mental_models primary key to be scoped per bank
Revision ID: w8r9s0t1u2v3
Revises: v7q8r9s0t1u2
Create Date: 2026-02-05
This migration fixes a critical bank isolation bug where mental_models.id was
globally unique across all banks instead of being scoped per bank. This caused
conflicts when different banks tried to use the same custom ID.
CRITICAL FIX: Changes primary key from (id) to (bank_id, id) to ensure proper isolation.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "w8r9s0t1u2v3"
down_revision: str | Sequence[str] | None = "v7q8r9s0t1u2"
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 primary key from (id) to (bank_id, id) for proper bank isolation."""
schema = _get_schema_prefix()
# Drop the old primary key constraint (just id)
# Note: The constraint might be named differently on different DBs
# Try both old names (pinned_reflections_pkey from original, mental_models_pkey from rename)
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS pinned_reflections_pkey")
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS mental_models_pkey")
# Create the new composite primary key (bank_id, id)
# This ensures IDs are scoped per bank, not globally
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT mental_models_pkey PRIMARY KEY (bank_id, id)
""")
def downgrade() -> None:
"""Revert mental_models primary key from (bank_id, id) to (id)."""
schema = _get_schema_prefix()
# Drop the composite primary key
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS mental_models_pkey")
# Restore the old primary key (just id)
# WARNING: This downgrade will fail if there are duplicate IDs across banks
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT mental_models_pkey PRIMARY KEY (id)
""")
+12 -10
View File
@@ -72,22 +72,24 @@ def create_app(
# Mount MCP server and chain its lifespan if enabled
if mcp_app is not None:
# Get the MCP app's underlying Starlette app for lifespan access
mcp_starlette_app = mcp_app.mcp_app
# Get both MCP apps' underlying Starlette apps for lifespan access
multi_bank_starlette_app = mcp_app.multi_bank_app
single_bank_starlette_app = mcp_app.single_bank_app
# Store the original lifespan
original_lifespan = app.router.lifespan_context
@asynccontextmanager
async def chained_lifespan(app_instance: FastAPI):
"""Chain the MCP lifespan with the main app lifespan."""
# Start MCP lifespan first
async with mcp_starlette_app.router.lifespan_context(mcp_starlette_app):
logger.info("MCP lifespan started")
# Then start the original app lifespan
async with original_lifespan(app_instance):
yield
logger.info("MCP lifespan stopped")
"""Chain both MCP lifespans with the main app lifespan."""
# Start both MCP lifespans (multi-bank and single-bank)
async with multi_bank_starlette_app.router.lifespan_context(multi_bank_starlette_app):
async with single_bank_starlette_app.router.lifespan_context(single_bank_starlette_app):
logger.info("MCP lifespans started (multi-bank and single-bank)")
# Then start the original app lifespan
async with original_lifespan(app_instance):
yield
logger.info("MCP lifespans stopped")
# Replace the app's lifespan with the chained version
app.router.lifespan_context = chained_lifespan
+78 -13
View File
@@ -5,6 +5,7 @@ This module provides the create_app function to create and configure
the FastAPI application with all API endpoints.
"""
import asyncio
import json
import logging
import uuid
@@ -35,7 +36,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from hindsight_api import MemoryEngine
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, fq_table
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
@@ -45,6 +46,8 @@ from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
class EntityIncludeOptions(BaseModel):
"""Options for including entity observations in recall results."""
@@ -520,7 +523,9 @@ class ReflectFact(BaseModel):
)
id: str | None = None
text: str
text: str = Field(
description="Fact text. When type='observation', this contains markdown-formatted consolidated knowledge"
)
type: str | None = None # fact type: world, experience, observation
context: str | None = None
occurred_start: str | None = None
@@ -585,7 +590,7 @@ class ReflectResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"text": "Based on my understanding, AI is a transformative technology...",
"text": "## AI Overview\n\nBased on my understanding, AI is a **transformative technology**:\n\n- Used extensively in healthcare\n- Discussed in recent conversations\n- Continues to evolve rapidly",
"based_on": {
"memories": [
{"id": "123", "text": "AI is used in healthcare", "type": "world"},
@@ -613,7 +618,9 @@ class ReflectResponse(BaseModel):
}
)
text: str
text: str = Field(
description="The reflect response as well-formatted markdown (headers, lists, bold/italic, code blocks, etc.)"
)
based_on: ReflectBasedOn | None = Field(
default=None,
description="Evidence used to generate the response. Only present when include.facts is set.",
@@ -863,6 +870,7 @@ class ListDocumentsResponse(BaseModel):
"updated_at": "2024-01-15T10:30:00Z",
"text_length": 5420,
"memory_unit_count": 15,
"tags": ["user_a", "session_123"],
}
],
"total": 50,
@@ -1110,7 +1118,9 @@ class MentalModelResponse(BaseModel):
bank_id: str
name: str
source_query: str
content: str
content: str = Field(
description="The mental model content as well-formatted markdown (auto-generated from reflect endpoint)"
)
tags: list[str] = Field(default_factory=list)
max_tokens: int = Field(default=2048)
trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger)
@@ -1134,6 +1144,7 @@ class CreateMentalModelRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "team-communication",
"name": "Team Communication Preferences",
"source_query": "How does the team prefer to communicate?",
"tags": ["team"],
@@ -1143,6 +1154,9 @@ class CreateMentalModelRequest(BaseModel):
}
)
id: str | None = Field(
None, description="Optional custom ID for the mental model (alphanumeric lowercase with hyphens)"
)
name: str = Field(description="Human-readable name for the mental model")
source_query: str = Field(description="The query to run to generate content")
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
@@ -1153,7 +1167,8 @@ class CreateMentalModelRequest(BaseModel):
class CreateMentalModelResponse(BaseModel):
"""Response model for mental model creation."""
operation_id: str = Field(description="Operation ID to track progress")
mental_model_id: str | None = Field(None, description="ID of the created mental model")
operation_id: str = Field(description="Operation ID to track refresh progress")
class UpdateMentalModelRequest(BaseModel):
@@ -1385,6 +1400,26 @@ def create_app(
app.state.prometheus_reader = None
# Metrics collector is already initialized as no-op by default
# Initialize OpenTelemetry tracing if enabled
if config.otel_traces_enabled:
if not config.otel_exporter_otlp_endpoint:
logging.warning("OTEL tracing enabled but no endpoint configured. Tracing disabled.")
else:
from hindsight_api.tracing import create_span_recorder, initialize_tracing
try:
initialize_tracing(
service_name=config.otel_service_name,
endpoint=config.otel_exporter_otlp_endpoint,
headers=config.otel_exporter_otlp_headers,
deployment_environment=config.otel_deployment_environment,
)
create_span_recorder()
logging.info("OpenTelemetry tracing enabled and configured")
except Exception as e:
logging.error(f"Failed to initialize tracing: {e}")
logging.warning("Continuing without tracing")
# Startup: Initialize database and memory system (migrations run inside initialize if enabled)
if initialize_memory:
await memory.initialize()
@@ -1417,6 +1452,12 @@ def create_app(
poller_task = asyncio.create_task(poller.run())
logging.info(f"Worker poller started (worker_id={worker_id})")
# Call tenant extension startup hook (e.g. JWKS fetch for Supabase)
tenant_extension = memory.tenant_extension
if tenant_extension:
await tenant_extension.on_startup()
logging.info("Tenant extension started")
# Call HTTP extension startup hook
if http_extension:
await http_extension.on_startup()
@@ -1435,6 +1476,11 @@ def create_app(
pass
logging.info("Worker poller stopped")
# Call tenant extension shutdown hook
if tenant_extension:
await tenant_extension.on_shutdown()
logging.info("Tenant extension stopped")
# Call HTTP extension shutdown hook
if http_extension:
await http_extension.on_shutdown()
@@ -1718,6 +1764,15 @@ def _register_routes(app: FastAPI):
handler_start = time.time()
metrics = get_metrics_collector()
# Validate query length to prevent expensive operations on oversized queries
encoding = _get_tiktoken_encoding()
query_tokens = len(encoding.encode(request.query))
if query_tokens > MAX_QUERY_TOKENS:
raise HTTPException(
status_code=400,
detail=f"Query too long: {query_tokens} tokens exceeds maximum of {MAX_QUERY_TOKENS}. Please shorten your query.",
)
try:
# Default to world and experience if not specified (exclude observation)
fact_types = request.types if request.types else list(VALID_RECALL_FACT_TYPES)
@@ -1832,6 +1887,15 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except (asyncio.TimeoutError, TimeoutError):
handler_duration = time.time() - handler_start
logger.error(
f"[RECALL TIMEOUT] bank={bank_id} handler_duration={handler_duration:.3f}s - database query timed out"
)
raise HTTPException(
status_code=504,
detail="Request timed out while searching memories. Try a shorter or more specific query.",
)
except Exception as e:
import traceback
@@ -1889,17 +1953,17 @@ def _register_routes(app: FastAPI):
directives = []
for fact_type, facts in core_result.based_on.items():
if fact_type == "directives":
# Directives have different structure (id, name, content)
# Directives are dicts with id, name, content (not MemoryFact objects)
for directive in facts:
directives.append(
ReflectDirective(
id=directive.id,
name=directive.name,
content=directive.content,
id=directive["id"],
name=directive["name"],
content=directive["content"],
)
)
elif fact_type == "mental_models":
# Mental models are MemoryFact with type "mental_models"
elif fact_type == "mental-models":
# Mental models are MemoryFact with type "mental-models" (note: hyphen, not underscore)
for fact in facts:
mental_models.append(
ReflectMentalModel(
@@ -2386,6 +2450,7 @@ def _register_routes(app: FastAPI):
name=body.name,
source_query=body.source_query,
content="Generating content...",
mental_model_id=body.id if body.id else None,
tags=body.tags if body.tags else None,
max_tokens=body.max_tokens,
trigger=body.trigger.model_dump() if body.trigger else None,
@@ -2397,7 +2462,7 @@ def _register_routes(app: FastAPI):
mental_model_id=mental_model["id"],
request_context=request_context,
)
return CreateMentalModelResponse(operation_id=result["operation_id"])
return CreateMentalModelResponse(mental_model_id=mental_model["id"], operation_id=result["operation_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
+135 -28
View File
@@ -8,7 +8,11 @@ from contextvars import ContextVar
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
from hindsight_api.models import RequestContext
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
@@ -29,7 +33,8 @@ logger = logging.getLogger(__name__)
# Default bank_id from environment variable
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
# MCP authentication token (optional - if set, Bearer token auth is required)
# Legacy MCP authentication token (for backwards compatibility)
# If set, this token is checked first before TenantExtension auth
MCP_AUTH_TOKEN = os.environ.get("HINDSIGHT_API_MCP_AUTH_TOKEN")
# Context variable to hold the current bank_id
@@ -38,6 +43,10 @@ _current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default
# Context variable to hold the current API key (for tenant auth propagation)
_current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None)
# Context variables for tenant_id and api_key_id (set by authenticate, used by usage metering)
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
@@ -49,12 +58,24 @@ def get_current_api_key() -> str | None:
return _current_api_key.get()
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
def get_current_tenant_id() -> str | None:
"""Get the current tenant_id from context."""
return _current_tenant_id.get()
def get_current_api_key_id() -> str | None:
"""Get the current api_key_id from context."""
return _current_api_key_id.get()
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
Args:
memory: MemoryEngine instance (required)
multi_bank: If True, expose all tools with bank_id parameters (default).
If False, only expose bank-scoped tools without bank_id parameters.
Returns:
Configured FastMCP server instance with stateless_http enabled
@@ -66,29 +87,52 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
tools=None, # All tools
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
include_bank_id_param=multi_bank,
tools=None if multi_bank else {"retain", "recall", "reflect"}, # Scoped tools for single-bank mode
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
)
register_mcp_tools(mcp, memory, config)
# Load and register additional tools from MCP extension if configured
mcp_extension = load_extension("MCP", MCPExtension)
if mcp_extension:
logger.info(f"Loading MCP extension: {mcp_extension.__class__.__name__}")
mcp_extension.register_tools(mcp, memory)
return mcp
class MCPMiddleware:
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
"""ASGI middleware that handles authentication and routes to appropriate MCP server.
Authentication:
If HINDSIGHT_API_MCP_AUTH_TOKEN is set, all requests must include a valid
Authorization header with Bearer token or direct token matching the configured value.
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
2. Otherwise, uses TenantExtension.authenticate_mcp() from the MemoryEngine
- DefaultTenantExtension: no auth required (local dev)
- ApiKeyTenantExtension: validates against env var
Bank ID can be provided via:
1. X-Bank-Id header (recommended for Claude Code)
2. URL path: /mcp/{bank_id}/
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback default)
Two modes based on URL structure:
For Claude Code, configure with:
1. Multi-bank mode (for /mcp/ root endpoint):
- Exposes all tools: retain, recall, reflect, list_banks, create_bank
- All tools include optional bank_id parameter for cross-bank operations
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var
2. Single-bank mode (for /mcp/{bank_id}/ endpoints):
- Exposes bank-scoped tools only: retain, recall, reflect
- No bank_id parameter (comes from URL)
- No bank management tools (list_banks, create_bank)
- Recommended for agent isolation
Examples:
# Single-bank mode (recommended for agent isolation)
claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/ \\
--header "Authorization: Bearer <token>"
# Multi-bank mode (for cross-bank operations)
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
"""
@@ -96,10 +140,24 @@ class MCPMiddleware:
def __init__(self, app, memory: MemoryEngine):
self.app = app
self.memory = memory
self.mcp_server = create_mcp_server(memory)
self.mcp_app = self.mcp_server.http_app(path="/")
# Expose the lifespan for the parent app to chain
self.lifespan = self.mcp_app.lifespan_handler if hasattr(self.mcp_app, "lifespan_handler") else None
self.tenant_extension = memory._tenant_extension
# Create two server instances:
# 1. Multi-bank server (for /mcp/ root endpoint)
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/")
# 2. Single-bank server (for /mcp/{bank_id}/ endpoints)
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
self.single_bank_app = self.single_bank_server.http_app(path="/")
# Backward compatibility: expose multi_bank_app as mcp_app
self.mcp_app = self.multi_bank_app
# Expose the lifespan for the parent app to chain (use multi-bank as default)
self.lifespan = (
self.multi_bank_app.lifespan_handler if hasattr(self.multi_bank_app, "lifespan_handler") else None
)
def _get_header(self, scope: dict, name: str) -> str | None:
"""Extract a header value from ASGI scope."""
@@ -111,7 +169,7 @@ class MCPMiddleware:
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.mcp_app(scope, receive, send)
await self.multi_bank_app(scope, receive, send)
return
# Extract auth token from header (for tenant auth propagation)
@@ -121,14 +179,36 @@ class MCPMiddleware:
# Support both "Bearer <token>" and direct token
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
# Authenticate if MCP_AUTH_TOKEN is configured
# Authenticate: check legacy MCP_AUTH_TOKEN first, then TenantExtension
tenant_context = None
auth_tenant_id: str | None = None
auth_api_key_id: str | None = None
if MCP_AUTH_TOKEN:
# Legacy authentication mode - validate against static token
if not auth_token:
await self._send_error(send, 401, "Authorization header required")
return
if auth_token != MCP_AUTH_TOKEN:
await self._send_error(send, 401, "Invalid authentication token")
return
# Legacy mode doesn't use tenant schemas
tenant_context = None
else:
# Use TenantExtension.authenticate_mcp() for auth
try:
auth_context = RequestContext(api_key=auth_token)
tenant_context = await self.tenant_extension.authenticate_mcp(auth_context)
# Capture tenant_id and api_key_id set by authenticate() for usage metering
auth_tenant_id = auth_context.tenant_id
auth_api_key_id = auth_context.api_key_id
except AuthenticationError as e:
await self._send_error(send, 401, str(e))
return
# Set schema from tenant context so downstream DB queries use the correct schema
schema_token = (
_current_schema.set(tenant_context.schema_name) if tenant_context and tenant_context.schema_name else None
)
path = scope.get("path", "")
@@ -143,8 +223,13 @@ class MCPMiddleware:
elif path == "/mcp":
path = "/"
# Ensure path has leading slash (needed after stripping mount path)
if path and not path.startswith("/"):
path = "/" + path
# Try to get bank_id from header first (for Claude Code compatibility)
bank_id = self._get_header(scope, "X-Bank-Id")
bank_id_from_path = False
# MCP endpoint paths that should not be treated as bank_ids
MCP_ENDPOINTS = {"sse", "messages"}
@@ -157,6 +242,7 @@ class MCPMiddleware:
if parts[0] and parts[0] not in MCP_ENDPOINTS:
# First segment looks like a bank_id
bank_id = parts[0]
bank_id_from_path = True
new_path = "/" + parts[1] if len(parts) > 1 else "/"
# Fall back to default bank_id
@@ -164,10 +250,18 @@ class MCPMiddleware:
bank_id = DEFAULT_BANK_ID
logger.debug(f"Using default bank_id: {bank_id}")
# Set bank_id and api_key context
# Select the appropriate MCP app based on how bank_id was provided:
# - Path-based bank_id → single-bank app (no bank_id param, scoped tools)
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
# Set bank_id, api_key, tenant_id, and api_key_id context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Store tenant_id and api_key_id from authentication for usage metering
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
try:
new_scope = scope.copy()
new_scope["path"] = new_path
@@ -176,7 +270,7 @@ class MCPMiddleware:
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing
async def send_wrapper(message):
if message["type"] == "http.response.body":
if message["type"] == "http.response.body" and bank_id_from_path:
body = message.get("body", b"")
if body and b"/messages" in body:
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
@@ -184,11 +278,17 @@ class MCPMiddleware:
message = {**message, "body": body}
await send(message)
await self.mcp_app(new_scope, receive, send_wrapper)
await target_app(new_scope, receive, send_wrapper)
finally:
_current_bank_id.reset(bank_id_token)
if api_key_token is not None:
_current_api_key.reset(api_key_token)
if tenant_id_token is not None:
_current_tenant_id.reset(tenant_id_token)
if api_key_id_token is not None:
_current_api_key_id.reset(api_key_id_token)
if schema_token is not None:
_current_schema.reset(schema_token)
async def _send_error(self, send, status: int, message: str):
"""Send an error response."""
@@ -210,16 +310,23 @@ class MCPMiddleware:
def create_mcp_app(memory: MemoryEngine):
"""
Create an ASGI app that handles MCP requests.
Create an ASGI app that handles MCP requests with dynamic tool exposure.
Authentication:
Set HINDSIGHT_API_MCP_AUTH_TOKEN to require Bearer token authentication.
If not set, MCP endpoint is open (for local development).
Uses the TenantExtension from the MemoryEngine (same auth as REST API).
Bank ID can be provided via:
1. X-Bank-Id header: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
2. URL path: /mcp/{bank_id}/
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback, default: "default")
Two modes based on URL structure:
1. Single-bank mode (recommended for agent isolation):
- URL: /mcp/{bank_id}/
- Tools: retain, recall, reflect (no bank_id parameter)
- Example: claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/
2. Multi-bank mode (for cross-bank operations):
- URL: /mcp/
- Tools: retain, recall, reflect, list_banks, create_bank (all with bank_id parameter)
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var (default: "default")
- Example: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
Args:
memory: MemoryEngine instance
+3 -1
View File
@@ -4,6 +4,8 @@ Banner display for Hindsight API startup.
Shows the logo and tagline with gradient colors.
"""
from .utils import mask_network_location
# Gradient colors: #0074d9 -> #009296
GRADIENT_START = (0, 116, 217) # #0074d9
GRADIENT_END = (0, 146, 150) # #009296
@@ -90,7 +92,7 @@ def print_startup_info(
if version:
print(f" {dim('Version:')} {color(f'v{version}', 0.1)}")
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
print(f" {dim('Database:')} {color(database_url, 0.4)}")
print(f" {dim('Database:')} {color(mask_network_location(database_url), 0.4)}")
print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}")
print(f" {dim('Embeddings:')} {color(embeddings_provider, 0.8)}")
print(f" {dim('Reranker:')} {color(reranker_provider, 1.0)}")
+45 -1
View File
@@ -108,6 +108,13 @@ 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"
# OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
@@ -251,6 +258,11 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
# OpenTelemetry tracing configuration
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -447,6 +459,29 @@ class HindsightConfig:
# Reflect agent settings
reflect_max_iterations: int
# OpenTelemetry tracing configuration
otel_traces_enabled: bool
otel_exporter_otlp_endpoint: str | None
otel_exporter_otlp_headers: str | None
otel_service_name: str
otel_deployment_environment: str
def validate(self) -> None:
"""Validate configuration values and raise errors for invalid combinations."""
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks
if self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than "
f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}"
f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens"
f"\n (current model: {self.retain_llm_model or self.llm_model}, "
f"provider: {self.retain_llm_provider or self.llm_provider})"
)
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
@@ -454,7 +489,7 @@ class HindsightConfig:
llm_provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
llm_model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(llm_provider)
return cls(
config = cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
@@ -630,7 +665,16 @@ class HindsightConfig:
),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
# OpenTelemetry tracing configuration
otel_traces_enabled=os.getenv(ENV_OTEL_TRACES_ENABLED, str(DEFAULT_OTEL_TRACES_ENABLED)).lower()
in ("true", "1", "yes"),
otel_exporter_otlp_endpoint=os.getenv(ENV_OTEL_EXPORTER_OTLP_ENDPOINT) or None,
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
)
config.validate()
return config
def get_llm_base_url(self) -> str:
"""Get the LLM base URL, with provider-specific defaults."""
@@ -143,6 +143,9 @@ async def run_consolidation_job(
"skipped": 0,
}
# Track all unique tags from consolidated memories for mental model refresh filtering
consolidated_tags: set[str] = set()
batch_num = 0
last_progress_timings = {} # Track timings at last progress log
while True:
@@ -176,6 +179,11 @@ async def run_consolidation_job(
for memory in memories:
mem_start = time.time()
# Track tags from this memory for mental model refresh filtering
memory_tags = memory.get("tags") or []
if memory_tags:
consolidated_tags.update(memory_tags)
# Process the memory (uses its own connection internally)
async with pool.acquire() as conn:
result = await _process_memory(
@@ -284,10 +292,12 @@ async def run_consolidation_job(
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
# Trigger mental model refreshes for models with refresh_after_consolidation=true
# SECURITY: Only refresh mental models with matching tags (or all if no tags were consolidated)
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
bank_id=bank_id,
request_context=request_context,
consolidated_tags=list(consolidated_tags) if consolidated_tags else None,
perf=perf,
)
stats["mental_models_refreshed"] = mental_models_refreshed
@@ -301,15 +311,20 @@ async def _trigger_mental_model_refreshes(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
consolidated_tags: list[str] | None = None,
perf: ConsolidationPerfLog | None = None,
) -> int:
"""
Trigger refreshes for mental models with refresh_after_consolidation=true.
SECURITY: Only triggers refresh for mental models whose tags overlap with the
consolidated memory tags, preventing unnecessary refreshes across security boundaries.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
consolidated_tags: Tags from memories that were consolidated (None = refresh all)
perf: Performance logging
Returns:
@@ -318,22 +333,52 @@ async def _trigger_mental_model_refreshes(
pool = memory_engine._pool
# Find mental models with refresh_after_consolidation=true
# SECURITY: Control which mental models get refreshed based on tags
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, name
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
""",
bank_id,
)
if consolidated_tags:
# Tagged memories were consolidated - refresh:
# 1. Mental models with overlapping tags (security boundary)
# 2. Untagged mental models (they're "global" and available to all contexts)
# DO NOT refresh mental models with different tags
rows = await conn.fetch(
f"""
SELECT id, name, tags
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
AND (
(tags IS NOT NULL AND tags != '{{}}' AND tags && $2::varchar[])
OR (tags IS NULL OR tags = '{{}}')
)
""",
bank_id,
consolidated_tags,
)
else:
# Untagged memories were consolidated - only refresh untagged mental models
# SECURITY: Tagged mental models are NOT refreshed when untagged memories are consolidated
rows = await conn.fetch(
f"""
SELECT id, name, tags
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
AND (tags IS NULL OR tags = '{{}}')
""",
bank_id,
)
if not rows:
return 0
if perf:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
if consolidated_tags:
perf.log(
f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true "
f"(filtered by tags: {consolidated_tags})"
)
else:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
# Submit refresh tasks for each mental model
refreshed_count = 0
@@ -381,92 +426,109 @@ async def _process_memory(
Returns:
Dict with action summary: created/updated/merged counts
"""
from ...tracing import get_tracer, is_tracing_enabled
fact_text = memory["text"]
memory_id = memory["id"]
fact_tags = memory.get("tags") or []
# Find related observations using the full recall system (NO tag filtering)
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
)
if perf:
perf.record_timing("recall", time.time() - t0)
# Create parent span for this memory's consolidation
tracer = get_tracer()
if is_tracing_enabled():
consolidation_span = tracer.start_span("hindsight.consolidation")
consolidation_span.set_attribute("hindsight.memory_id", str(memory_id))
consolidation_span.set_attribute("hindsight.bank_id", bank_id)
else:
consolidation_span = None
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
try:
# Find related observations using the full recall system
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
tags=fact_tags, # Pass source memory's tags for security
)
if perf:
perf.record_timing("recall", time.time() - t0)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
if not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
# 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 not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
if len(results) == 1:
return results[0]
# 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")
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
if len(results) == 1:
return results[0]
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
finally:
if consolidation_span:
consolidation_span.end()
async def _execute_update_action(
@@ -666,34 +728,57 @@ async def _find_related_observations(
bank_id: str,
query: str,
request_context: "RequestContext",
tags: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
Find observations related to the given query using optimized recall.
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
potentially related observations regardless of scope, so the LLM can
decide on tag routing (same scope update vs cross-scope create).
SECURITY: Filters by tags using all_strict matching to prevent cross-tenant/cross-user
information leakage. Observations are only consolidated within the same tag scope.
Uses max_tokens to naturally limit observations (no artificial count limit).
Includes source memories with dates for LLM context.
Args:
tags: Optional tags to filter observations (uses all_strict matching for security)
Returns:
List of related observations with their tags, source memories, and dates
"""
# Use recall to find related observations with token budget
# max_tokens naturally limits how many observations are returned
from ...config import get_config
from ...tracing import get_tracer, is_tracing_enabled
config = get_config()
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
_quiet=True, # Suppress logging
# NO tags parameter - intentionally get ALL observations
)
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
tags_match = "all_strict" if tags else "any"
# Create span for recall operation within consolidation
tracer = get_tracer()
if is_tracing_enabled():
recall_span = tracer.start_span("hindsight.consolidation_recall")
recall_span.set_attribute("hindsight.bank_id", bank_id)
recall_span.set_attribute("hindsight.query", query[:100]) # Truncate for brevity
recall_span.set_attribute("hindsight.fact_type", "observation")
else:
recall_span = None
try:
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
_quiet=True, # Suppress logging
)
finally:
if recall_span:
recall_span.end()
# If no observations returned, return empty list
if not recall_result.results:
@@ -2,7 +2,7 @@
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (observations) and merge with existing knowledge when appropriate.
You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text.
You must output ONLY valid JSON with no markdown code blocks or additional text. However, the "text" field within each observation should use markdown formatting (headers, lists, bold, etc.) for clarity and readability.
## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
@@ -32,13 +32,16 @@ BAD examples:
## MERGE RULES (when comparing to existing observations):
1. REDUNDANT: Same information worded differently → update existing
2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
3. UPDATE: New state replacing old state → update with history
2. CONTRADICTION: Opposite information about same topic → update with temporal markers showing change
Example: "Alex used to love pizza but now hates it" OR "Alex's pizza preference changed from love to hate"
3. UPDATE: New state replacing old state → update showing the transition with "used to", "now", "changed from X to Y"
## 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)
- When merging contradictions, the "text" field MUST capture BOTH states with temporal markers:
* Use "used to X, now Y" OR "changed from X to Y" OR "X but now Y"
* DO NOT just state the new fact - you MUST show the change
- Keep observations focused on ONE specific topic per person
- The "text" field MUST contain durable knowledge, not ephemeral state
- Do NOT include "tags" in output - tags are handled automatically"""
@@ -68,10 +71,15 @@ Instructions:
- New topic → CREATE new observation
- Purely ephemeral → return []
Output JSON array of actions:
Output JSON array of actions (the "text" field should use markdown formatting for structure):
[
{{"action": "update", "learning_id": "uuid-from-observations", "text": "updated knowledge", "reason": "..."}},
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
{{"action": "update", "learning_id": "uuid-from-observations", "text": "## Updated Knowledge\n\n**Key point**: details here\n\n- Supporting detail 1\n- Supporting detail 2", "reason": "..."}},
{{"action": "create", "text": "## New Durable Knowledge\n\nDescription with **emphasis** and proper structure", "reason": "..."}}
]
Return [] if fact contains no durable knowledge."""
Return [] if fact contains no durable knowledge.
IMPORTANT: Format the "text" field with markdown for better readability:
- Use headers, lists, bold/italic, tables where appropriate
- CRITICAL: Add blank lines before and after block elements (tables, code blocks, lists)
- Ensure proper spacing for markdown to render correctly"""
@@ -9,6 +9,7 @@ Configuration via environment variables - see hindsight_api.config for all env v
import asyncio
import logging
import os
import warnings
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
@@ -162,11 +163,28 @@ class LocalSTCrossEncoder(CrossEncoderModel):
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from CrossEncoder which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", message=".*was not found in model state dict.*")
warnings.filterwarnings("ignore", message=".*UNEXPECTED.*")
# Also suppress transformers library logging temporarily
transformers_logger = logging.getLogger("transformers")
original_level = transformers_logger.level
transformers_logger.setLevel(logging.ERROR)
try:
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
finally:
# Restore original logging level
transformers_logger.setLevel(original_level)
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
@@ -11,6 +11,7 @@ Configuration via environment variables - see hindsight_api.config for all env v
import logging
import os
import warnings
from abc import ABC, abstractmethod
import httpx
@@ -157,11 +158,28 @@ class LocalSTEmbeddings(Embeddings):
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", message=".*was not found in model state dict.*")
warnings.filterwarnings("ignore", message=".*UNEXPECTED.*")
# Also suppress transformers library logging temporarily
transformers_logger = logging.getLogger("transformers")
original_level = transformers_logger.level
transformers_logger.setLevel(logging.ERROR)
try:
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
finally:
# Restore original logging level
transformers_logger.setLevel(original_level)
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
File diff suppressed because it is too large Load Diff
@@ -84,7 +84,7 @@ class AnthropicLLM(LLMInterface):
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
scope="verification",
max_retries=0,
)
logger.info("Anthropic connection verified successfully")
@@ -223,6 +223,24 @@ class AnthropicLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.stop_reason if hasattr(response, "stop_reason") else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0:
logger.info(
@@ -397,16 +415,41 @@ class AnthropicLLM(LLMInterface):
# Record metrics
metrics = get_metrics_collector()
duration = time.time() - start_time
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=time.time() - start_time,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -95,7 +95,7 @@ class ClaudeCodeLLM(LLMInterface):
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
scope="verification",
max_retries=0,
)
logger.info("Claude Code connection verified successfully")
@@ -237,6 +237,23 @@ class ClaudeCodeLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
# Log slow calls
if duration > 10.0:
logger.info(
@@ -291,61 +308,202 @@ class ClaudeCodeLLM(LLMInterface):
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Make an LLM API call with tool/function calling support using Claude Agent SDK.
Note: This is a simplified implementation. Full tool support would require
integrating with Claude Agent SDK's tool system.
This implementation uses ClaudeSDKClient (not query()) because custom tools via
SDK MCP servers are only supported with the client. Tools are converted from OpenAI
format to SDK MCP tools, and tool names are formatted as mcp__hindsight_tools__{name}.
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.
max_completion_tokens: Maximum tokens in response (not used by Claude Agent SDK).
temperature: Sampling temperature (not used by Claude Agent SDK).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
tool_choice: How to choose tools (not used by Claude Agent SDK).
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
# For now, use regular call without tools
# Full implementation would require mapping OpenAI tool format to Claude Agent SDK tools
logger.warning(
"Claude Code provider does not fully support tool calling yet. Falling back to regular text completion."
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
SdkMcpTool,
TextBlock,
ToolUseBlock,
create_sdk_mcp_server,
)
result = await self.call(
messages=messages,
response_format=None,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
return_usage=True,
start_time = time.time()
# Convert OpenAI tool format to Claude Agent SDK SdkMcpTool format
sdk_tools: list[SdkMcpTool] = []
tool_names: list[str] = []
for tool in tools:
func = tool.get("function", {})
tool_name = func.get("name", "")
tool_description = func.get("description", "")
parameters = func.get("parameters", {})
# Create a handler with proper closure to avoid transport issues
def make_handler(name: str):
async def handler(args: dict[str, Any]) -> dict[str, Any]:
# Return immediately with success - tool execution happens externally
return {
"content": [
{
"type": "text",
"text": f"[Tool {name} called successfully]",
}
]
}
return handler
sdk_tools.append(
SdkMcpTool(
name=tool_name,
description=tool_description,
input_schema=parameters,
handler=make_handler(tool_name),
)
)
tool_names.append(tool_name)
# Create an MCP server with the tools
mcp_server = create_sdk_mcp_server(
name="hindsight_tools",
version="1.0.0",
tools=sdk_tools if sdk_tools else None,
)
if isinstance(result, tuple):
text, usage = result
return LLMToolCallResult(
content=text,
tool_calls=[],
finish_reason="stop",
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
else:
# Fallback if return_usage didn't work as expected
return LLMToolCallResult(
content=str(result),
tool_calls=[],
finish_reason="stop",
input_tokens=0,
output_tokens=0,
)
# Build system prompt and user content from messages
system_prompt = ""
user_content = ""
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt += ("\n\n" + content) if system_prompt else content
elif role == "user":
user_content += ("\n\n" + content) if user_content else content
elif role == "assistant":
# Include previous assistant messages as context
user_content += f"\n\n[Previous assistant response: {content}]"
elif role == "tool":
# Tool results are already in tool_results_map, append to user context
tool_call_id = msg.get("tool_call_id", "")
user_content += f"\n\n[Tool result for {tool_call_id}: {content}]"
# Format tool names for SDK MCP servers: mcp__{server_name}__{tool_name}
# This is required by the Claude Agent SDK for MCP server tools
allowed_tool_names = [f"mcp__hindsight_tools__{name}" for name in tool_names]
# Configure SDK options with MCP server
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
max_turns=1, # Single-turn for API-style interactions
mcp_servers={"hindsight_tools": mcp_server} if sdk_tools else {},
allowed_tools=allowed_tool_names if allowed_tool_names else [],
)
# Call Claude Agent SDK with retry logic
last_exception = None
for attempt in range(max_retries + 1):
try:
full_text = ""
tool_calls: list[LLMToolCall] = []
# Use ClaudeSDKClient for tool calling support
# Note: query() does NOT support custom tools, only ClaudeSDKClient does
async with ClaudeSDKClient(options=options) as client:
# Send the query
await client.query(user_content)
# Receive response
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(block, ToolUseBlock):
# SDK returns tool names with MCP prefix (mcp__hindsight_tools__{name})
# Strip the prefix to return original tool name expected by caller
tool_name = block.name
if tool_name.startswith("mcp__hindsight_tools__"):
tool_name = tool_name.replace("mcp__hindsight_tools__", "", 1)
tool_calls.append(
LLMToolCall(
id=block.id,
name=tool_name,
arguments=block.input,
)
)
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
# Estimate token usage (Claude Agent SDK doesn't report exact counts)
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(full_text) // 4
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=estimated_input,
output_tokens=estimated_output,
success=True,
)
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, time={duration:.3f}s"
)
return LLMToolCallResult(
content=full_text if full_text else None,
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
input_tokens=estimated_input,
output_tokens=estimated_output,
)
except Exception as e:
last_exception = e
# Check for authentication errors
error_str = str(e).lower()
if "auth" in error_str or "login" in error_str or "credential" in error_str:
logger.error(f"Claude Code authentication error: {e}")
raise RuntimeError(
f"Claude Code authentication failed: {e}\n\n"
"Run 'claude auth login' to authenticate with Claude Pro/Max."
) from e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Claude Code tool call error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Claude Code tool call error after {max_retries + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Claude Code tool call failed after all retries")
async def cleanup(self) -> None:
"""Clean up resources (no HTTP client to close for Claude Agent SDK)."""
@@ -136,6 +136,7 @@ class CodexLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Codex LLM verified: {self.model}")
except Exception as e:
@@ -177,6 +178,9 @@ class CodexLLM(LLMInterface):
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
system_instruction += schema_msg
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
# Build Codex request payload
payload = {
"model": self.model,
@@ -192,7 +196,7 @@ class CodexLLM(LLMInterface):
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": True,
"reasoning": {"summary": self.reasoning_summary},
"reasoning": {"summary": reasoning_summary},
"store": False, # Codex uses stateless mode
"stream": True, # SSE streaming
"include": ["reasoning.encrypted_content"],
@@ -258,6 +262,26 @@ class CodexLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
# Estimate tokens for tracing
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
if return_usage:
# Codex doesn't provide token counts, estimate based on content
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
@@ -283,13 +307,20 @@ class CodexLLM(LLMInterface):
"Run 'codex auth login' to re-authenticate."
) from e
# Log the actual error message from the API
error_detail = e.response.text[:500] if hasattr(e.response, "text") else str(e)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1})")
logger.warning(
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Codex HTTP error after {max_retries + 1} attempts: {e}")
logger.error(
f"Codex HTTP error after {max_retries + 1} attempts: Status {status_code}, Detail: {error_detail}"
)
raise
except httpx.RequestError as e:
@@ -379,8 +410,22 @@ class CodexLLM(LLMInterface):
"""
Make API call with tool calling support.
Note: This is a basic implementation. Full tool calling support for Codex
may require additional SSE event parsing.
Parses Codex SSE stream to extract tool calls from response.output_item.done events.
Tools are converted from OpenAI format to Codex format (flat structure at top level).
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature.
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
@@ -413,20 +458,22 @@ class CodexLLM(LLMInterface):
)
# Convert tools to Codex format
# Codex expects tools with type and name/description/parameters at top level
codex_tools = []
for tool in tools:
func = tool.get("function", {})
codex_tools.append(
{
"type": "function",
"function": {
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
},
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
}
)
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
payload = {
"model": self.model,
"instructions": system_instruction,
@@ -434,7 +481,7 @@ class CodexLLM(LLMInterface):
"tools": codex_tools,
"tool_choice": tool_choice,
"parallel_tool_calls": True,
"reasoning": {"summary": self.reasoning_summary},
"reasoning": {"summary": reasoning_summary},
"store": False,
"stream": True,
"include": ["reasoning.encrypted_content"],
@@ -451,8 +498,16 @@ class CodexLLM(LLMInterface):
url = f"{self.base_url}/codex/responses"
# Debug logging for troubleshooting
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
# Log response details on error
if response.status_code != 200:
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
response.raise_for_status()
# Parse SSE for tool calls and content
@@ -470,6 +525,28 @@ class CodexLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls] if tool_calls else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=0, # Codex doesn't provide token counts
output_tokens=0,
duration=duration,
finish_reason="tool_calls" if tool_calls else "stop",
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -512,13 +589,30 @@ class CodexLLM(LLMInterface):
if event_type == "response.text.delta" and "delta" in data:
content += data["delta"]
# Extract tool calls
elif event_type == "response.function_call_arguments.delta":
# Handle tool call events (implementation depends on actual Codex SSE format)
pass
# Extract completed tool calls from response.output_item.done
elif event_type == "response.output_item.done":
item = data.get("item", {})
if item.get("type") == "function_call" and item.get("status") == "completed":
tool_name = item.get("name", "")
arguments_str = item.get("arguments", "{}")
call_id = item.get("call_id", "")
except json.JSONDecodeError:
pass
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
tool_calls.append(
LLMToolCall(
id=call_id,
name=tool_name,
arguments=arguments,
)
)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {e}, data_str: {data_str[:200]}")
return content if content else None, tool_calls
@@ -136,6 +136,7 @@ class GeminiLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"{self.provider.upper()} connection verified successfully")
except Exception as e:
@@ -275,6 +276,29 @@ class GeminiLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
finish_reason = None
if hasattr(response, "candidates") and response.candidates:
if hasattr(response.candidates[0], "finish_reason"):
finish_reason = str(response.candidates[0].finish_reason)
span_recorder = get_span_recorder()
from hindsight_api.tracing import _serialize_for_span
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0 and input_tokens > 0:
logger.info(
@@ -466,6 +490,30 @@ class GeminiLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -65,6 +65,7 @@ class MockLLM(LLMInterface):
# Storage for test verification
self._mock_calls: list[dict] = []
self._mock_response: Any = None
self._mock_exception: Exception | None = None
async def verify_connection(self) -> None:
"""
@@ -124,6 +125,27 @@ class MockLLM(LLMInterface):
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
# Raise mock exception if configured
if self._mock_exception is not None:
raise self._mock_exception
# Record trace span (minimal for mock provider)
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content="mock response",
input_tokens=10,
output_tokens=5,
duration=0.001, # Mock calls are instant
finish_reason="stop",
error=None,
)
# Return mock response
if self._mock_response is not None:
result = self._mock_response
@@ -183,20 +205,54 @@ class MockLLM(LLMInterface):
}
self._mock_calls.append(call_record)
# Raise mock exception if configured
if self._mock_exception is not None:
raise self._mock_exception
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
if self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
return self._mock_response
# Allow setting just tool calls as a list
if isinstance(self._mock_response, list):
return LLMToolCallResult(
result = self._mock_response
elif isinstance(self._mock_response, list):
# Allow setting just tool calls as a list
result = 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",
)
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
return LLMToolCallResult(content="mock response", finish_reason="stop")
# Record span with mock values
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in result.tool_calls]
if result.tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result.content,
input_tokens=10, # Mock value
output_tokens=5, # Mock value
duration=0.1, # Mock value
finish_reason=result.finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return result
async def cleanup(self) -> None:
"""Clean up resources (no-op for mock provider)."""
@@ -215,6 +271,16 @@ class MockLLM(LLMInterface):
"""
self._mock_response = response
def set_mock_exception(self, exception: Exception) -> None:
"""
Set an exception to raise from mock calls.
Args:
exception: The exception to raise on the next call.
After raising, the exception is cleared.
"""
self._mock_exception = exception
def get_mock_calls(self) -> list[dict]:
"""
Get the list of recorded mock calls.
@@ -230,5 +296,6 @@ class MockLLM(LLMInterface):
return self._mock_calls
def clear_mock_calls(self) -> None:
"""Clear the recorded mock calls."""
"""Clear the recorded mock calls and any set exception."""
self._mock_calls = []
self._mock_exception = None
@@ -130,6 +130,7 @@ class OpenAICompatibleLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Connection verified: {self.provider}/{self.model}")
except Exception as e:
@@ -368,6 +369,24 @@ class OpenAICompatibleLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.choices[0].finish_reason if response.choices else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0 and usage:
ratio = max(1, output_tokens) / max(1, input_tokens)
@@ -556,6 +575,30 @@ class OpenAICompatibleLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -402,7 +402,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -447,7 +447,7 @@ async def run_reflect_agent(
result = await llm_config.call_with_tools(
messages=messages,
tools=tools,
scope="reflect_agent",
scope="reflect_tool_call",
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
)
llm_duration = int((time.time() - llm_start) * 1000)
@@ -479,7 +479,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -550,7 +550,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -617,23 +617,30 @@ async def run_reflect_agent(
)
continue
# Process done tool
return await _process_done_tool(
done_call,
available_memory_ids,
available_mental_model_ids,
available_observation_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,
)
# Process done tool - wrap with tool call span
from hindsight_api.tracing import get_tracer
tracer = get_tracer()
span_name = "hindsight.reflect_tool_call"
with tracer.start_as_current_span(span_name) as span:
span.set_attribute("hindsight.scope", "reflect_tool_call")
span.set_attribute("hindsight.operation", "reflect_tool_call")
return await _process_done_tool(
done_call,
available_memory_ids,
available_mental_model_ids,
available_observation_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)]
@@ -842,17 +849,67 @@ async def _execute_tool_with_timing(
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_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
duration_ms = int((time.time() - start) * 1000)
return result, duration_ms
from hindsight_api.tracing import get_tracer
start_time = time.time()
# Create span for tool execution
tracer = get_tracer()
# Normalize tool name for span
normalized_name = _normalize_tool_name(tc.name)
span_name = f"hindsight.reflect_tool_exec.{normalized_name}"
# Calculate timestamps
start_time_ns = time.time_ns()
with tracer.start_as_current_span(
span_name,
start_time=start_time_ns,
end_on_exit=False,
) as span:
# Set attributes
span.set_attribute("hindsight.tool.name", normalized_name)
span.set_attribute("hindsight.tool.id", tc.id)
span.set_attribute("hindsight.tool.arguments", json.dumps(tc.arguments))
try:
result = await _execute_tool(
tc.name,
tc.arguments,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
# Set success attributes
if isinstance(result, dict) and "error" in result:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.ERROR, result["error"]))
else:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.OK))
duration_ms = int((time.time() - start_time) * 1000)
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
# End span with correct timestamp
end_time_ns = time.time_ns()
span.end(end_time=end_time_ns)
return result, duration_ms
except Exception as e:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
duration_ms = int((time.time() - start_time) * 1000)
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
end_time_ns = time.time_ns()
span.end(end_time=end_time_ns)
raise
async def _execute_tool(
@@ -871,21 +928,21 @@ async def _execute_tool(
query = args.get("query")
if not query:
return {"error": "search_mental_models requires a query parameter"}
max_results = args.get("max_results") or 5
max_results = int(args.get("max_results") or 5)
return await search_mental_models_fn(query, max_results)
elif tool_name == "search_observations":
query = args.get("query")
if not query:
return {"error": "search_observations requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
max_tokens = max(int(args.get("max_tokens") or 5000), 1000) # Default 5000, min 1000
return await search_observations_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
max_tokens = max(int(args.get("max_tokens") or 2048), 1000) # Default 2048, min 1000
return await recall_fn(query, max_tokens)
elif tool_name == "expand":
@@ -904,18 +961,18 @@ def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
if tool_name == "search_mental_models":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_results = args.get("max_results") or 5
max_results = int(args.get("max_results") or 5)
return f"(query={query_preview}, max_results={max_results})"
elif tool_name == "search_observations":
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)
max_tokens = max(int(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)
max_tokens = max(int(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", [])
@@ -31,7 +31,7 @@ class ReflectAction(BaseModel):
default=None, description="Observation sections 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: str | None = Field(default=None, description="Well-formatted markdown answer for done action")
answer_memory_ids: list[str] | None = Field(
default=None, description="Memory IDs supporting the answer", alias="memory_ids"
)
@@ -148,7 +148,15 @@ def build_system_prompt_for_tools(
parts = []
# Inject directives at the VERY START for maximum prominence
# Anti-hallucination rule at the very top
parts.extend(
[
"CRITICAL: You MUST ONLY use information from retrieved tool results. NEVER make up names, people, events, or entities.",
"",
]
)
# Inject directives after anti-hallucination rule
if directives:
parts.append(build_directives_section(directives))
@@ -162,7 +170,7 @@ def build_system_prompt_for_tools(
parts.extend(
[
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- ONLY use information from tool results - no external knowledge or guessing",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
"- You MUST search before saying you don't have information",
"",
@@ -300,9 +308,11 @@ def build_system_prompt_for_tools(
parts.extend(
[
"",
"## Output Format: Plain Text Answer",
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"## Output Format: Well-Formatted Markdown Answer",
"Call done() with a well-formatted markdown 'answer' field.",
"- USE markdown formatting for structure (headers, lists, bold, italic, code blocks, tables, etc.)",
"- CRITICAL: Add blank lines before and after block elements (tables, code blocks, lists)",
"- Format for clarity and readability with proper spacing and hierarchy",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
"- Put IDs ONLY in the memory_ids/mental_model_ids/observation_ids arrays, not in the answer",
]
@@ -463,21 +473,41 @@ def build_final_prompt(
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."
"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."
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question.\n\n"
"IMPORTANT: Output ONLY the final answer. Do NOT include meta-commentary like "
'"I\'ll search..." or "Let me analyze...". Do NOT explain your reasoning process. '
"Just provide the direct synthesized answer."
)
return "\n".join(parts)
FINAL_SYSTEM_PROMPT = """You are a thoughtful assistant that synthesizes answers from retrieved memories.
FINAL_SYSTEM_PROMPT = """CRITICAL: You MUST ONLY use information from retrieved tool results. NEVER make up names, people, events, or entities.
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 use information from tool results - no external knowledge or guessing
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."""
FORMATTING: Use proper markdown formatting in your answer:
- Headers (##, ###) for sections
- Lists (bullet or numbered) for enumerations
- Bold/italic for emphasis
- Tables with proper syntax (ensure blank line before and after)
- Code blocks where appropriate
- CRITICAL: Always add blank lines before and after block elements (tables, code blocks, lists)
- Proper spacing between sections
CRITICAL: Output ONLY the final synthesized answer. Do NOT include:
- Meta-commentary about what you're doing ("I'll search...", "Let me analyze...")
- Explanations of your reasoning process
- Descriptions of your approach
Just provide the direct answer with proper markdown formatting."""
@@ -54,19 +54,18 @@ async def tool_search_mental_models(
Dict with matching mental models including content and freshness info
"""
from ..memory_engine import fq_table
from ..search.tags import build_tags_where_clause
# Build filters dynamically
filters = ""
params: list[Any] = [bank_id, str(query_embedding), max_results]
next_param = 4
# Use the centralized tag filtering logic
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
tag_clause, tag_params, next_param = build_tags_where_clause(tags, param_offset=next_param, match=tags_match)
filters += f" {tag_clause}"
params.extend(tag_params)
if exclude_ids:
filters += f" AND id != ALL(${next_param}::text[])"
@@ -139,7 +139,7 @@ TOOL_DONE_ANSWER = {
"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.",
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
@@ -190,7 +190,7 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
"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.",
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
@@ -542,7 +542,12 @@ Output: ONLY 2 facts (skip coffee preference - too trivial):
QUALITY OVER QUANTITY
══════════════════════════════════════════════════════════════════════════
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
Ask: "Would this be useful to recall in 6 months?" If no, skip it.
IMPORTANT: Sensory/emotional details and observations that provide meaningful context
about experiences ARE important to remember, even if they seem small (e.g., how food
tasted, how someone looked, how loud music was). Extract these if they characterize
an experience or person."""
# Assembled concise prompt (backward compatible - exact same output as before)
CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
@@ -641,6 +646,7 @@ For EVENTS (fact_kind="event") - MUST SET BOTH occurred_start AND occurred_end:
- Convert relative dates → absolute using Event Date as reference
- If Event Date is "Saturday, March 15, 2020", then "yesterday" = Friday, March 14, 2020
- Dates mentioned in text (e.g., "in March 2020") should use THAT year, not current year
- CRITICAL: If the content mentions an absolute date (e.g., "March 15, 2024", "2024-03-15"), you MUST extract it and set occurred_start in ISO format
- Always include the day name (Monday, Tuesday, etc.) in the 'when' field
- Set occurred_start AND occurred_end to WHEN IT HAPPENED (not when mentioned)
- For single-day/point events: set occurred_end = occurred_start (same timestamp)
@@ -796,7 +802,7 @@ Text:
extraction_response_json, call_usage = await llm_config.call(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="memory_extract_facts",
scope="retain_extract_facts",
temperature=0.1,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=max_retries,
@@ -1005,6 +1011,29 @@ Text:
except BadRequestError as e:
last_error = e
error_str = str(e).lower()
# Check if error is related to max_tokens/completion_tokens not being supported
if any(
keyword in error_str
for keyword in [
"max_tokens",
"max_completion_tokens",
"maximum context",
"token limit",
"context length",
]
):
# Provide helpful error message with configuration suggestions
raise ValueError(
f"Model does not support the required output token limit.\n\n"
f"The model '{llm_config.model}' (provider: {llm_config.provider}) failed with: {e}\n\n"
f"You have two options to fix this:\n"
f" 1. Use a different model that supports at least {config.retain_max_completion_tokens} output tokens\n"
f" 2. Decrease HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value your model supports\n"
f" (current value: {config.retain_max_completion_tokens}, must be > RETAIN_CHUNK_SIZE={config.retain_chunk_size})"
) from e
if "json_validate_failed" in str(e):
logger.warning(
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}"
@@ -1347,28 +1376,21 @@ def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[C
def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainContent]) -> None:
"""
Add time offsets to preserve fact ordering within each content.
Add time offsets to preserve fact ordering across all contents.
This allows retrieval to distinguish between facts that happened earlier vs later
in the same conversation, even when the base event_date is the same.
This allows retrieval to distinguish between facts from different documents/conversations
even when they have the same base event_date, and also between facts within the same
conversation.
Uses absolute position across all facts to ensure unique timestamps.
Modifies facts in place.
"""
from .orchestrator import parse_datetime_flexible
# Group facts by content_index
current_content_idx = 0
content_fact_start = 0
for i, fact in enumerate(facts):
if fact.content_index != current_content_idx:
# Moved to next content
current_content_idx = fact.content_index
content_fact_start = i
# Calculate position within this content
fact_position = i - content_fact_start
offset = timedelta(seconds=fact_position * SECONDS_PER_FACT)
# Use absolute position across all facts to ensure uniqueness across different contents
offset = timedelta(seconds=i * SECONDS_PER_FACT)
# Apply offset to all temporal fields (handle both datetime objects and ISO strings)
if fact.occurred_start:
@@ -158,6 +158,13 @@ async def retain_batch(
# Handle document tracking even with no facts
if document_id:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if contents_dicts:
first_item = contents_dicts[0]
@@ -172,7 +179,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, document_tags
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
else:
# Check for per-item document_ids
@@ -186,6 +193,13 @@ async def retain_batch(
for doc_id, doc_contents in contents_by_doc.items():
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
@@ -200,7 +214,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, document_tags
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params, merged_tags
)
total_time = time.time() - start_time
@@ -252,6 +266,13 @@ async def retain_batch(
# Legacy: single document_id parameter
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params = {}
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
if contents_dicts:
first_item = contents_dicts[0]
if first_item.get("context"):
@@ -266,7 +287,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, document_tags
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
document_ids_added.append(document_id)
doc_id_mapping[None] = document_id # For backwards compatibility
@@ -294,6 +315,13 @@ async def retain_batch(
# Combine content for this document
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
# Extract retain params from first content item
retain_params = {}
if doc_contents:
@@ -316,7 +344,7 @@ async def retain_batch(
combined_content,
is_first_batch,
retain_params,
document_tags,
merged_tags,
)
document_ids_added.append(actual_doc_id)
@@ -188,7 +188,7 @@ def get_system_message(disposition: DispositionTraits) -> str:
" ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
)
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."
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. CRITICAL: ONLY use the facts and information provided in the prompt - do not make up names, events, or information that weren't mentioned. If you don't have enough information to answer, say so. 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 reflect(
@@ -16,10 +16,11 @@ with the system (e.g., running migrations for tenant schemas).
"""
from hindsight_api.extensions.base import Extension
from hindsight_api.extensions.builtin import ApiKeyTenantExtension
from hindsight_api.extensions.builtin import ApiKeyTenantExtension, SupabaseTenantExtension
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
from hindsight_api.extensions.http import HttpExtension
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.mcp import MCPExtension
from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
@@ -57,6 +58,8 @@ __all__ = [
"DefaultExtensionContext",
# HTTP Extension
"HttpExtension",
# MCP Extension
"MCPExtension",
# Operation Validator - Core
"OperationValidationError",
"OperationValidatorExtension",
@@ -77,6 +80,7 @@ __all__ = [
"MentalModelRefreshResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"SupabaseTenantExtension",
"AuthenticationError",
"RequestContext",
"Tenant",
@@ -6,13 +6,17 @@ They can be used directly or serve as examples for custom implementations.
Available built-in extensions:
- ApiKeyTenantExtension: Simple API key validation with public schema
- SupabaseTenantExtension: Supabase JWT validation with per-user schema isolation
Example usage:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
"""
from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
__all__ = [
"ApiKeyTenantExtension",
"SupabaseTenantExtension",
]
@@ -0,0 +1,433 @@
"""
Supabase Tenant Extension for Hindsight
Validates Supabase JWTs and maps authenticated users to isolated memory banks.
Each user gets their own PostgreSQL schema based on their Supabase user ID.
This extension enables multi-tenant memory isolation for applications using
Supabase Auth - each authenticated user's memories are stored in a separate
schema, ensuring complete data isolation.
Features:
- Local JWT Verification: Validates tokens locally using JWKS public keys
(no network call per request)
- Automatic Schema Isolation: Each user gets {prefix}_{user_id} schema
- Zero User Management: Leverages your existing Supabase Auth setup
- Production Ready: Includes health checks, timeouts, key rotation handling,
and error handling
- Built-in: Ships with Hindsight, no extra installation needed
- Legacy Support: Falls back to /auth/v1/user endpoint for HS256 projects
JWT Verification Strategy:
By default, JWTs are verified locally using public keys from the Supabase
JWKS endpoint (/auth/v1/.well-known/jwks.json). This is the Supabase-recommended
approach: no network call per request, fast, and secure.
If JWKS keys are unavailable (e.g., legacy HS256 projects), the extension
falls back to calling /auth/v1/user per request for validation. This requires
the service_role key to be configured.
Configuration via environment variables:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
HINDSIGHT_API_TENANT_SUPABASE_URL=https://your-project.supabase.co
# Optional - only required for legacy HS256 projects or health checks
HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY=your-service-role-key
# Optional
HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user # Default: "user" (creates user_<uuid> schemas)
Usage:
Clients pass their Supabase JWT in the Authorization header:
curl -H "Authorization: Bearer <supabase_jwt>" \\
https://your-hindsight-server/v1/default/banks/my-bank/memories/recall
Author: BrighterBalance (https://brighterbalance.app)
License: MIT
"""
from __future__ import annotations
import logging
import re
import time
import httpx
import jwt as pyjwt
from jwt import PyJWK
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
__all__ = ["SupabaseTenantExtension"]
# Minimum expected JWT length (JWTs are typically 100+ characters)
MIN_TOKEN_LENGTH = 20
# Timeout for Supabase API calls
REQUEST_TIMEOUT_SECONDS = 10.0
# JWKS cache TTL — Supabase Edge caches JWKS for 10 minutes, so we match that
JWKS_CACHE_TTL_SECONDS = 600
# Minimum interval between JWKS refreshes to avoid hammering the endpoint
JWKS_MIN_REFRESH_INTERVAL_SECONDS = 30
# Algorithms supported by Supabase Auth for asymmetric JWT signing
SUPPORTED_ALGORITHMS = ["RS256", "ES256"]
# Supabase user IDs are UUIDs — validate before using in schema names
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
# Schema prefix must be a valid Postgres identifier component (letters, digits, underscores)
_SCHEMA_PREFIX_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
class SupabaseTenantExtension(TenantExtension):
"""
TenantExtension that validates Supabase JWTs for multi-tenant isolation.
Each authenticated user gets their own PostgreSQL schema, ensuring complete
memory isolation between users. The schema name is derived from the user's
Supabase user ID (the ``sub`` claim in the JWT).
JWT verification uses JWKS (local, no network call per request) when
asymmetric keys are configured in Supabase, and falls back to the
``/auth/v1/user`` endpoint for legacy HS256 projects.
Example:
User with ID "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
gets schema "user_a1b2c3d4_e5f6_7890_abcd_ef1234567890"
"""
def __init__(self, config: dict[str, str]) -> None:
"""
Initialize with configuration from environment variables.
Config keys are derived from HINDSIGHT_API_TENANT_* env vars:
- HINDSIGHT_API_TENANT_SUPABASE_URL -> config["supabase_url"] (required)
- HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY -> config["supabase_service_key"] (optional)
- HINDSIGHT_API_TENANT_SCHEMA_PREFIX -> config["schema_prefix"] (optional)
Args:
config: Dictionary of configuration values from environment
Raises:
ValueError: If required configuration is missing
"""
super().__init__(config)
self.supabase_url = (config.get("supabase_url") or "").rstrip("/")
self.supabase_service_key = config.get("supabase_service_key")
self.schema_prefix = config.get("schema_prefix", "user")
# Track initialized schemas to avoid redundant migrations
self._initialized_schemas: set[str] = set()
# Reusable HTTP client (created on startup)
self._http_client: httpx.AsyncClient | None = None
# JWKS state
self._jwks_keys: dict[str, PyJWK] = {}
self._jwks_last_fetched: float = 0
self._use_jwks: bool = False
if not self.supabase_url:
raise ValueError(
"HINDSIGHT_API_TENANT_SUPABASE_URL is required. "
"Set it to your Supabase project URL (e.g., https://xxx.supabase.co)"
)
if not _SCHEMA_PREFIX_RE.match(self.schema_prefix):
raise ValueError(
f"Invalid schema_prefix '{self.schema_prefix}'. "
"Must be a valid Postgres identifier (letters, digits, underscores, starting with a letter or underscore)."
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def on_startup(self) -> None:
"""
Called when Hindsight starts.
Creates a reusable HTTP client, fetches JWKS for local JWT verification,
and optionally verifies connectivity to Supabase.
"""
logger.info("Initializing Supabase tenant extension")
logger.info("Supabase URL: %s", self.supabase_url)
logger.info("Schema prefix: %s_", self.schema_prefix)
self._http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
# Attempt to fetch JWKS for fast local JWT verification
await self._try_init_jwks()
# Optional health check using service key
if self.supabase_service_key:
await self._health_check()
async def on_shutdown(self) -> None:
"""Called when Hindsight shuts down. Closes the HTTP client."""
logger.info("Shutting down Supabase tenant extension")
if self._http_client:
await self._http_client.aclose()
self._http_client = None
# ------------------------------------------------------------------
# JWKS management
# ------------------------------------------------------------------
async def _try_init_jwks(self) -> None:
"""Fetch JWKS and decide verification mode (local JWKS vs legacy endpoint)."""
try:
await self._fetch_jwks()
if self._jwks_keys:
self._use_jwks = True
logger.info(
"JWKS loaded — using local JWT verification with %d key(s)",
len(self._jwks_keys),
)
return
# JWKS endpoint returned no keys — project likely uses legacy HS256
logger.warning(
"JWKS endpoint returned no signing keys. "
"Falling back to /auth/v1/user endpoint for JWT verification. "
"For better performance, enable asymmetric JWT signing in your "
"Supabase dashboard (Project Settings → Auth → JWT Algorithm)."
)
except Exception as e:
logger.warning(
"Could not fetch JWKS (%s). Falling back to /auth/v1/user endpoint for JWT verification.",
e,
)
# Legacy mode requires service key
if not self.supabase_service_key:
raise ValueError(
"HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY is required when JWKS "
"is not available. Either enable asymmetric JWT signing in your "
"Supabase project or provide the service_role key."
)
self._use_jwks = False
async def _fetch_jwks(self) -> None:
"""Fetch public signing keys from the Supabase JWKS endpoint."""
if self._http_client is None:
raise RuntimeError("HTTP client not initialized")
url = f"{self.supabase_url}/auth/v1/.well-known/jwks.json"
response = await self._http_client.get(url)
response.raise_for_status()
jwks_data = response.json()
keys: dict[str, PyJWK] = {}
for key_data in jwks_data.get("keys", []):
kid = key_data.get("kid")
if kid:
keys[kid] = PyJWK(key_data)
self._jwks_keys = keys
self._jwks_last_fetched = time.monotonic()
async def _get_signing_key(self, token: str) -> PyJWK:
"""
Resolve the signing key for a token from the JWKS cache.
If the key ID (``kid``) is not in the cache, triggers one JWKS refresh
to handle key rotation before raising an error.
"""
header = pyjwt.get_unverified_header(token)
kid = header.get("kid")
if not kid:
raise AuthenticationError("Token missing key ID (kid) header")
# Refresh cache if stale
now = time.monotonic()
if now - self._jwks_last_fetched > JWKS_CACHE_TTL_SECONDS:
logger.debug("JWKS cache expired, refreshing")
await self._fetch_jwks()
if kid in self._jwks_keys:
return self._jwks_keys[kid]
# Key not found — try one forced refresh to handle key rotation,
# but only if we haven't just refreshed
if now - self._jwks_last_fetched > JWKS_MIN_REFRESH_INTERVAL_SECONDS:
logger.info("Signing key %s not in cache, refreshing JWKS for possible key rotation", kid)
await self._fetch_jwks()
if kid in self._jwks_keys:
return self._jwks_keys[kid]
raise AuthenticationError("Unable to find signing key for token")
# ------------------------------------------------------------------
# Authentication
# ------------------------------------------------------------------
async def authenticate(self, context: RequestContext) -> TenantContext:
"""
Validate a Supabase JWT and return tenant context.
Uses local JWKS verification when available (no network call per
request), falling back to the ``/auth/v1/user`` endpoint for legacy
HS256 projects.
Args:
context: Request context containing the API key (JWT)
Returns:
TenantContext with schema_name set to ``{prefix}_{user_uuid}``
Raises:
AuthenticationError: If token is missing, invalid, or expired
"""
token = context.api_key
if not token:
raise AuthenticationError("Missing Authorization header. Expected: Bearer <supabase_jwt>")
if len(token) < MIN_TOKEN_LENGTH:
raise AuthenticationError("Invalid token format")
if self._http_client is None:
raise AuthenticationError("Extension not initialized")
# Verify the JWT and extract user ID
if self._use_jwks:
user_id = await self._verify_token_jwks(token)
else:
user_id = await self._verify_token_legacy(token)
# Validate user ID format before using in schema name
if not _UUID_RE.match(user_id):
raise AuthenticationError("Invalid user ID format in token")
# Build isolated schema name — hyphens to underscores for Postgres compatibility
safe_user_id = user_id.replace("-", "_")
schema_name = f"{self.schema_prefix}_{safe_user_id}"
# Initialize schema on first access
if schema_name not in self._initialized_schemas:
await self._initialize_schema(schema_name)
return TenantContext(schema_name=schema_name)
async def _verify_token_jwks(self, token: str) -> str:
"""
Verify a JWT locally using cached JWKS public keys.
Validates signature, expiration, issuer, and audience. Returns the
user ID from the ``sub`` claim.
Raises:
AuthenticationError: If the token is invalid or expired.
"""
try:
signing_key = await self._get_signing_key(token)
payload = pyjwt.decode(
token,
signing_key.key,
algorithms=SUPPORTED_ALGORITHMS,
audience="authenticated",
issuer=f"{self.supabase_url}/auth/v1",
)
except pyjwt.ExpiredSignatureError:
raise AuthenticationError("Token has expired")
except pyjwt.InvalidAudienceError:
raise AuthenticationError("Invalid token audience")
except pyjwt.InvalidIssuerError:
raise AuthenticationError("Invalid token issuer")
except pyjwt.DecodeError:
raise AuthenticationError("Invalid token")
except AuthenticationError:
raise
except Exception as e:
raise AuthenticationError(f"Token verification failed: {e!s}")
user_id = payload.get("sub")
if not user_id:
raise AuthenticationError("Token valid but missing subject (sub) claim")
return user_id
async def _verify_token_legacy(self, token: str) -> str:
"""
Verify a JWT by calling the Supabase ``/auth/v1/user`` endpoint.
This is the fallback for projects using legacy HS256 JWT signing.
Adds a network round-trip per request.
Raises:
AuthenticationError: If the token is invalid or the request fails.
"""
try:
response = await self._http_client.get(
f"{self.supabase_url}/auth/v1/user",
headers={
"Authorization": f"Bearer {token}",
"apikey": self.supabase_service_key,
},
)
if response.status_code == 401:
raise AuthenticationError("Invalid or expired token")
if response.status_code != 200:
raise AuthenticationError(f"Authentication failed: {response.status_code}")
user_data = response.json()
user_id = user_data.get("id")
if not user_id:
raise AuthenticationError("Token valid but no user ID found")
return user_id
except AuthenticationError:
raise
except httpx.TimeoutException:
raise AuthenticationError("Authentication timeout - please retry")
except httpx.RequestError as e:
raise AuthenticationError(f"Connection error: {e!s}")
# ------------------------------------------------------------------
# Schema management
# ------------------------------------------------------------------
async def _initialize_schema(self, schema_name: str) -> None:
"""Run migrations for a new tenant schema and cache the result."""
logger.info("Initializing schema: %s", schema_name)
try:
await self.context.run_migration(schema_name)
self._initialized_schemas.add(schema_name)
logger.info("Schema ready: %s", schema_name)
except Exception as e:
logger.error("Schema initialization failed for %s: %s", schema_name, e)
raise AuthenticationError(f"Failed to initialize tenant: {e!s}")
async def list_tenants(self) -> list[Tenant]:
"""Return all tenant schemas that have been initialized."""
return [Tenant(schema=schema) for schema in self._initialized_schemas]
# ------------------------------------------------------------------
# Health check
# ------------------------------------------------------------------
async def _health_check(self) -> None:
"""Verify connectivity to Supabase using the auth health endpoint."""
try:
response = await self._http_client.get(
f"{self.supabase_url}/auth/v1/health",
headers={"apikey": self.supabase_service_key},
)
if response.status_code == 200:
logger.info("Supabase connection verified")
else:
logger.warning("Supabase health check returned %d", response.status_code)
except Exception as e:
logger.warning("Could not verify Supabase connection: %s", e)
@@ -54,6 +54,7 @@ class ApiKeyTenantExtension(TenantExtension):
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true (optional, disable auth for MCP endpoints)
For multi-tenant setups with separate schemas per tenant, implement a custom
TenantExtension that looks up the schema based on the API key or token claims.
@@ -64,6 +65,8 @@ class ApiKeyTenantExtension(TenantExtension):
self.expected_api_key = config.get("api_key")
if not self.expected_api_key:
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
# Allow disabling MCP auth for backwards compatibility
self.mcp_auth_disabled = config.get("mcp_auth_disabled", "").lower() in ("true", "1", "yes")
async def authenticate(self, context: RequestContext) -> TenantContext:
"""Validate API key and return configured schema context."""
@@ -74,3 +77,14 @@ class ApiKeyTenantExtension(TenantExtension):
async def list_tenants(self) -> list[Tenant]:
"""Return configured schema for single-tenant setup."""
return [Tenant(schema=get_config().database_schema)]
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
"""
Authenticate MCP requests.
If mcp_auth_disabled is set, skip authentication for backwards compatibility.
Otherwise, delegate to authenticate().
"""
if self.mcp_auth_disabled:
return TenantContext(schema_name=get_config().database_schema)
return await self.authenticate(context)
@@ -0,0 +1,42 @@
"""MCP Extension for registering additional MCP tools.
This extension allows external packages (like hindsight-cloud) to register
additional MCP tools on the Hindsight MCP server.
Example:
HINDSIGHT_API_MCP_EXTENSION=hindsight_cloud.extensions:CloudMCPExtension
"""
import logging
from abc import abstractmethod
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.extensions.base import Extension
logger = logging.getLogger(__name__)
class MCPExtension(Extension):
"""Base class for MCP extensions that register additional tools.
Subclass this to add MCP tools in extension packages.
Example:
class CloudMCPExtension(MCPExtension):
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
@mcp.tool()
async def my_custom_tool(query: str) -> str:
return "result"
"""
@abstractmethod
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
"""Register additional MCP tools.
Args:
mcp: FastMCP server instance to register tools on
memory: MemoryEngine instance for accessing memory operations
"""
pass
@@ -87,3 +87,22 @@ class TenantExtension(Extension, ABC):
For single-tenant setups, return [Tenant(schema="public")].
"""
...
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
"""
Authenticate MCP requests.
By default, this calls authenticate(). Override this method to provide
different authentication behavior for MCP endpoints (e.g., to disable
auth for backwards compatibility with existing MCP servers).
Args:
context: The action context containing API key and other auth data.
Returns:
TenantContext with the schema_name for database operations.
Raises:
AuthenticationError: If authentication fails.
"""
return await self.authenticate(context)
+5
View File
@@ -242,6 +242,11 @@ def main():
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
reflect_max_iterations=config.reflect_max_iterations,
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
otel_traces_enabled=config.otel_traces_enabled,
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
otel_service_name=config.otel_service_name,
otel_deployment_environment=config.otel_deployment_environment,
)
config.configure_logging()
if not args.daemon:
+12 -4
View File
@@ -35,6 +35,12 @@ class MCPToolsConfig:
# How to resolve API key for tenant auth (optional)
api_key_resolver: Callable[[], str | None] | None = None
# How to resolve tenant_id for usage metering (set by MCP middleware after auth)
tenant_id_resolver: Callable[[], str | None] | None = None
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
api_key_id_resolver: Callable[[], str | None] | None = None
# Whether to include bank_id as a parameter on tools (for multi-bank support)
include_bank_id_param: bool = False
@@ -50,13 +56,15 @@ class MCPToolsConfig:
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
"""Create RequestContext with API key from resolver if available.
"""Create RequestContext with auth details from resolvers.
This enables tenant auth to work with MCP tools by propagating
the Bearer token from the MCP middleware to the memory engine.
This enables tenant auth and usage metering to work with MCP tools by propagating
the authentication results from the MCP middleware to the memory engine.
"""
api_key = config.api_key_resolver() if config.api_key_resolver else None
return RequestContext(api_key=api_key)
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
def parse_timestamp(timestamp: str) -> datetime | None:
+3 -1
View File
@@ -25,6 +25,8 @@ from alembic.config import Config
from alembic.script.revision import ResolutionError
from sqlalchemy import create_engine, text
from .utils import mask_network_location
logger = logging.getLogger(__name__)
# Advisory lock ID for migrations (arbitrary unique number)
@@ -54,7 +56,7 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
"""
schema_name = schema or "public"
logger.info(f"Running database migrations to head for schema '{schema_name}'...")
logger.info(f"Database URL: {database_url}")
logger.info(f"Database URL: {mask_network_location(database_url)}")
logger.info(f"Script location: {script_location}")
# Create Alembic configuration programmatically (no alembic.ini needed)
+480
View File
@@ -0,0 +1,480 @@
"""
OpenTelemetry distributed tracing instrumentation for Hindsight API.
This module provides tracing for:
- LLM API calls with full prompts/completions following GenAI semantic conventions
- Token usage and model information
- Error tracking and finish reasons
Tracing is conditional and disabled by default. When enabled, traces are exported
to Langfuse (or any OTLP-compatible backend) via OTLP HTTP protocol.
"""
import json
import logging
import time
from typing import Any, Optional
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import Status, StatusCode
logger = logging.getLogger(__name__)
def _serialize_for_span(obj: Any) -> str:
"""Serialize an object for span recording, handling Pydantic models."""
if isinstance(obj, str):
return obj
if hasattr(obj, "model_dump_json"):
# Pydantic v2 model
return obj.model_dump_json()
if hasattr(obj, "json"):
# Pydantic v1 model
return obj.json()
if hasattr(obj, "model_dump"):
# Pydantic v2 model - convert to dict then json
return json.dumps(obj.model_dump())
if hasattr(obj, "dict"):
# Pydantic v1 model - convert to dict then json
return json.dumps(obj.dict())
# Fallback to json.dumps for dicts and other types
return json.dumps(obj)
# No-op tracer for when tracing is disabled
class NoOpTracer:
"""No-op tracer that provides the same interface as OpenTelemetry Tracer but does nothing."""
def start_as_current_span(self, name: str, **kwargs):
"""Return a no-op context manager that yields a NoOpSpan."""
from contextlib import contextmanager
@contextmanager
def noop_span_context():
yield NoOpSpan()
return noop_span_context()
def start_span(self, name: str, **kwargs):
"""Return a no-op span."""
return NoOpSpan()
class NoOpSpan:
"""No-op span that provides the same interface as OpenTelemetry Span but does nothing."""
def set_attribute(self, key: str, value: Any) -> None:
"""No-op."""
pass
def set_status(self, status: Any) -> None:
"""No-op."""
pass
def record_exception(self, exception: Exception) -> None:
"""No-op."""
pass
def add_event(self, name: str, attributes: dict | None = None) -> None:
"""No-op."""
pass
def end(self, end_time: int | None = None) -> None:
"""No-op."""
pass
# Global tracer instance
_tracer: trace.Tracer | NoOpTracer = NoOpTracer()
_tracing_enabled: bool = False
# GenAI semantic convention attribute names (based on v1.37 spec)
class GenAIAttributes:
"""GenAI semantic convention attribute names."""
# Operation and provider
OPERATION_NAME = "gen_ai.operation.name"
PROVIDER_NAME = "gen_ai.provider.name"
# Model information
REQUEST_MODEL = "gen_ai.request.model"
RESPONSE_MODEL = "gen_ai.response.model"
# Token usage
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
# Messages and prompts
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
INPUT_MESSAGES = "gen_ai.input.messages"
OUTPUT_MESSAGES = "gen_ai.output.messages"
# Response metadata
FINISH_REASONS = "gen_ai.response.finish_reasons"
# Error tracking
ERROR_TYPE = "error.type"
# Provider name mapping (Hindsight internal -> GenAI semantic convention)
PROVIDER_NAME_MAPPING = {
"openai": "openai",
"anthropic": "anthropic",
"gemini": "google",
"vertexai": "google",
"groq": "groq",
"ollama": "ollama",
"lmstudio": "lmstudio",
"openai-codex": "openai",
"claude-code": "anthropic",
"mock": "mock",
}
def initialize_tracing(
service_name: str,
endpoint: str,
headers: Optional[str] = None,
deployment_environment: str = "development",
) -> None:
"""
Initialize OpenTelemetry tracing with OTLP exporter.
Args:
service_name: Name of the service for resource attributes
endpoint: OTLP endpoint URL (e.g., https://cloud.langfuse.com/api/public/otel)
headers: Optional headers in format "key1=value1,key2=value2"
deployment_environment: Deployment environment (e.g., development, staging, production)
"""
global _tracer, _tracing_enabled
# Create resource with service information
resource = Resource.create(
{
"service.name": service_name,
"service.version": "0.4.8", # Could import from __version__
"deployment.environment.name": deployment_environment,
}
)
# Parse headers
headers_dict = {}
if headers:
for pair in headers.split(","):
if "=" in pair:
key, value = pair.split("=", 1)
headers_dict[key.strip()] = value.strip()
# Create OTLP HTTP exporter
# Note: Langfuse expects /v1/traces path appended to base endpoint
otlp_endpoint = endpoint if endpoint.endswith("/v1/traces") else f"{endpoint}/v1/traces"
otlp_exporter = OTLPSpanExporter(
endpoint=otlp_endpoint,
headers=headers_dict,
)
# Create tracer provider with batch processor
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# Set global tracer provider
trace.set_tracer_provider(provider)
# Get tracer for this application
_tracer = trace.get_tracer(__name__)
_tracing_enabled = True
logger.info(f"Tracing initialized: endpoint={otlp_endpoint}, service={service_name}")
def get_tracer() -> trace.Tracer | NoOpTracer:
"""
Get the global tracer instance.
Returns a no-op tracer if tracing is disabled, so callers don't need to check for None.
This improves code readability by allowing direct use without null checks.
"""
return _tracer
def create_operation_span(operation: str, bank_id: str | None = None):
"""
Create a parent span for a Hindsight operation (retain, reflect, consolidation, etc.).
This creates the span hierarchy:
- hindsight.{operation} (parent)
- chat {model} (child LLM calls)
Args:
operation: Operation name (retain, reflect, consolidation, mental_model_refresh)
bank_id: Optional bank ID for context
Returns:
Span context manager
"""
if not _tracing_enabled or _tracer is None:
# Return a no-op context manager
from contextlib import nullcontext
return nullcontext()
span_name = f"hindsight.{operation}"
span = _tracer.start_as_current_span(span_name)
# Add operation-specific attributes
if span and hasattr(span, "set_attribute"):
span.set_attribute("hindsight.operation", operation)
if bank_id:
span.set_attribute("hindsight.bank_id", bank_id)
return span
def is_tracing_enabled() -> bool:
"""Check if tracing is enabled."""
return _tracing_enabled
# Maximum content length before truncation (to stay within span size limits)
MAX_CONTENT_LENGTH = 100_000 # characters
def _truncate_content(content: str) -> str:
"""Truncate content if too large for span."""
if len(content) > MAX_CONTENT_LENGTH:
return content[:MAX_CONTENT_LENGTH] + f"\n\n[TRUNCATED: {len(content) - MAX_CONTENT_LENGTH} chars omitted]"
return content
class LLMSpanRecorder:
"""
Records OpenTelemetry spans for LLM calls following GenAI semantic conventions.
"""
def __init__(self, tracer: trace.Tracer):
self.tracer = tracer
def record_llm_call(
self,
provider: str,
model: str,
scope: str,
messages: list[dict[str, str]],
response_content: Optional[str],
input_tokens: int,
output_tokens: int,
duration: float,
finish_reason: Optional[str] = None,
error: Optional[Exception] = None,
tool_calls: Optional[list[dict[str, Any]]] = None,
) -> None:
"""
Record a completed LLM call as a span with GenAI semantic conventions.
This creates a span AFTER the call completes, using timestamps to
set the correct start/end times. This approach works better with
the existing sync metrics recording pattern.
Args:
provider: Hindsight provider name
model: Model name
scope: Scope identifier (memory, reflect, consolidation, etc.)
messages: Input messages (chat history)
response_content: Response text from LLM
input_tokens: Input token count
output_tokens: Output token count
duration: Call duration in seconds
finish_reason: Reason the model stopped (stop, length, tool_calls, etc.)
error: Exception if call failed
tool_calls: List of tool calls made (for function calling)
"""
try:
# Map provider name to GenAI semantic convention
genai_provider = PROVIDER_NAME_MAPPING.get(provider.lower(), provider.lower())
# Determine operation name based on scope/context
operation_name = "chat" # Default for GenAI semantic conventions
# Create span name: "hindsight.{scope}" for consistency with parent spans
# Model info is available in span attributes (gen_ai.request.model)
if scope:
span_name = f"hindsight.{scope}"
else:
# Fallback to chat {model} if no scope provided
span_name = f"{operation_name} {model}"
# Calculate timestamps
end_time_ns = time.time_ns()
start_time_ns = end_time_ns - int(duration * 1_000_000_000)
# Create span with explicit timestamps
with self.tracer.start_as_current_span(
span_name,
start_time=start_time_ns,
end_on_exit=False, # We'll set end time manually
) as span:
# Set required attributes
span.set_attribute(GenAIAttributes.OPERATION_NAME, operation_name)
span.set_attribute(GenAIAttributes.PROVIDER_NAME, genai_provider)
span.set_attribute(GenAIAttributes.REQUEST_MODEL, model)
span.set_attribute(GenAIAttributes.RESPONSE_MODEL, model)
span.set_attribute(GenAIAttributes.USAGE_INPUT_TOKENS, input_tokens)
span.set_attribute(GenAIAttributes.USAGE_OUTPUT_TOKENS, output_tokens)
# Add custom attributes for Hindsight context
span.set_attribute("hindsight.scope", scope)
span.set_attribute("hindsight.provider.internal", provider)
# Add tool call information if present
if tool_calls:
span.set_attribute("gen_ai.tool_calls.count", len(tool_calls))
# Add tool names as comma-separated list
tool_names = [tc.get("name", "") for tc in tool_calls]
span.set_attribute("gen_ai.tool_calls.names", ",".join(tool_names))
# Format messages for GenAI conventions (as JSON)
input_messages_json = self._format_messages(messages)
output_messages_json = self._format_output(response_content, finish_reason)
# Extract system instructions if present
system_instructions = self._extract_system_instructions(messages)
# Add event with prompts/completions following v1.37 conventions
event_attrs = {}
if input_messages_json:
event_attrs[GenAIAttributes.INPUT_MESSAGES] = input_messages_json
if output_messages_json:
event_attrs[GenAIAttributes.OUTPUT_MESSAGES] = output_messages_json
if system_instructions:
event_attrs[GenAIAttributes.SYSTEM_INSTRUCTIONS] = system_instructions
if finish_reason:
event_attrs[GenAIAttributes.FINISH_REASONS] = json.dumps([finish_reason])
span.add_event(
"gen_ai.client.inference.operation.details",
attributes=event_attrs,
)
# Add individual tool call events with details
if tool_calls:
for i, tc in enumerate(tool_calls):
tool_event_attrs = {
"tool.name": tc.get("name", ""),
"tool.id": tc.get("id", ""),
"tool.arguments": json.dumps(tc.get("arguments", {})),
}
span.add_event(f"gen_ai.tool_call.{i}", attributes=tool_event_attrs)
# Handle errors
if error:
span.set_status(Status(StatusCode.ERROR, str(error)))
span.set_attribute(GenAIAttributes.ERROR_TYPE, type(error).__name__)
span.record_exception(error)
else:
span.set_status(Status(StatusCode.OK))
# Set end time
span.end(end_time=end_time_ns)
except Exception as e:
# Don't let tracing errors break LLM calls
logger.error(f"Failed to record LLM span: {e}", exc_info=True)
def _format_messages(self, messages: list[dict[str, str]]) -> str:
"""
Format messages into GenAI semantic convention format (JSON array).
Returns JSON string representation of message array.
"""
try:
formatted = []
for msg in messages:
content = msg.get("content", "")
# Truncate if needed
if isinstance(content, str):
content = _truncate_content(content)
formatted.append(
{
"role": msg.get("role", "user"),
"content": content,
}
)
return json.dumps(formatted)
except Exception as e:
logger.warning(f"Failed to format input messages: {e}")
return "[]"
def _format_output(
self,
content: Optional[str],
finish_reason: Optional[str],
) -> str:
"""Format output message into GenAI semantic convention format."""
try:
if content is None:
return "[]"
# Truncate if needed
if isinstance(content, str):
content = _truncate_content(content)
return json.dumps(
[
{
"role": "assistant",
"content": content,
}
]
)
except Exception as e:
logger.warning(f"Failed to format output message: {e}")
return "[]"
def _extract_system_instructions(self, messages: list[dict[str, str]]) -> Optional[str]:
"""Extract system instructions from messages if present."""
try:
for msg in messages:
if msg.get("role") == "system":
content = msg.get("content", "")
if isinstance(content, str):
return _truncate_content(content)
return str(content)
except Exception as e:
logger.warning(f"Failed to extract system instructions: {e}")
return None
class NoOpLLMSpanRecorder:
"""No-op span recorder for when tracing is disabled."""
def record_llm_call(self, **kwargs) -> None:
"""No-op."""
pass
# Global span recorder instance
_span_recorder: Optional[LLMSpanRecorder] = None
def get_span_recorder() -> LLMSpanRecorder | NoOpLLMSpanRecorder:
"""Get the global span recorder (NoOp if tracing disabled)."""
if _span_recorder is None:
return NoOpLLMSpanRecorder()
return _span_recorder
def create_span_recorder() -> LLMSpanRecorder:
"""Create and set the global span recorder."""
global _span_recorder
tracer = get_tracer()
if tracer is None:
raise RuntimeError("Tracing not initialized. Call initialize_tracing() first.")
_span_recorder = LLMSpanRecorder(tracer)
return _span_recorder
+13
View File
@@ -0,0 +1,13 @@
from urllib.parse import urlparse, urlunparse
def mask_network_location(url):
if not url:
return url
parsed_url = urlparse(url)
masked_network_location = parsed_url.hostname or ""
if parsed_url.port:
masked_network_location += f":{parsed_url.port}"
if parsed_url.username or parsed_url.password:
masked_network_location = f"***:***@{masked_network_location}"
return urlunparse(parsed_url._replace(netloc=masked_network_location))
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.4.7"
version = "0.4.10"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -25,6 +25,7 @@ dependencies = [
"psycopg2-binary>=2.9.11",
"tiktoken>=0.12.0",
"httpx>=0.27.0",
"PyJWT[crypto]>=2.8.0",
"fastmcp>=2.14.0", # CVE-2025-66416
"pg0-embedded>=0.11.0",
"python-dateutil>=2.8.0",
@@ -32,6 +33,8 @@ dependencies = [
"opentelemetry-sdk>=1.20.0",
"opentelemetry-instrumentation-fastapi>=0.41b0",
"opentelemetry-exporter-prometheus>=0.41b0",
"opentelemetry-exporter-otlp-proto-http>=1.20.0",
"opentelemetry-semantic-conventions>=0.41b0",
"dateparser>=1.2.2",
"google-genai>=1.0.0",
"google-auth>=2.0.0",
+31
View File
@@ -220,3 +220,34 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
await mem.close()
except Exception:
pass
@pytest_asyncio.fixture(scope="function")
async def memory_no_llm_verify(pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""
Provide a MemoryEngine instance that skips LLM connection verification.
This fixture is useful for tests that override the LLM configuration
after initialization (e.g., to test specific providers).
"""
mem = MemoryEngine(
db_url=pg0_db_url,
memory_llm_provider="mock", # Use mock provider as placeholder
memory_llm_api_key="",
memory_llm_model="mock",
embeddings=embeddings,
cross_encoder=cross_encoder,
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=5,
run_migrations=False,
task_backend=SyncTaskBackend(),
skip_llm_verification=True, # Skip verification - will be overridden by test
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
@@ -0,0 +1,109 @@
"""
Tests for configuration validation.
Verifies that config validation catches invalid parameter combinations.
"""
import os
import pytest
@pytest.fixture(autouse=True)
def setup_test_env():
"""Set up environment for each test, restoring original values after."""
from hindsight_api.config import clear_config_cache
# Save original environment values
env_vars_to_save = [
"HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
"HINDSIGHT_API_RETAIN_CHUNK_SIZE",
"HINDSIGHT_API_LLM_PROVIDER",
"HINDSIGHT_API_LLM_MODEL",
]
# Save original values
original_values = {}
for key in env_vars_to_save:
original_values[key] = os.environ.get(key)
clear_config_cache()
yield
# Restore original environment
for key, original_value in original_values.items():
if original_value is None:
os.environ.pop(key, None)
else:
os.environ[key] = original_value
clear_config_cache()
def test_retain_max_completion_tokens_must_be_greater_than_chunk_size():
"""Test that RETAIN_MAX_COMPLETION_TOKENS > RETAIN_CHUNK_SIZE validation works."""
from hindsight_api.config import HindsightConfig
# Set invalid config: max_completion_tokens <= chunk_size
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "1000"
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "2000"
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
# Should raise ValueError with helpful message
with pytest.raises(ValueError) as exc_info:
HindsightConfig.from_env()
error_message = str(exc_info.value)
# Verify error message contains helpful information
assert "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message
assert "1000" in error_message
assert "HINDSIGHT_API_RETAIN_CHUNK_SIZE" in error_message
assert "2000" in error_message
assert "must be greater than" in error_message
assert "You have two options to fix this:" in error_message
assert "Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message
assert "Use a model that supports" in error_message
def test_retain_max_completion_tokens_equal_to_chunk_size_fails():
"""Test that RETAIN_MAX_COMPLETION_TOKENS == RETAIN_CHUNK_SIZE also fails."""
from hindsight_api.config import HindsightConfig
# Set invalid config: max_completion_tokens == chunk_size
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "3000"
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000"
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
# Should raise ValueError
with pytest.raises(ValueError) as exc_info:
HindsightConfig.from_env()
error_message = str(exc_info.value)
assert "must be greater than" in error_message
def test_valid_retain_config_succeeds():
"""Test that valid config with max_completion_tokens > chunk_size works."""
from hindsight_api.config import HindsightConfig
# Set valid config: max_completion_tokens > chunk_size
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "64000"
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000"
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
# Should not raise
config = HindsightConfig.from_env()
assert config.retain_max_completion_tokens == 64000
assert config.retain_chunk_size == 3000
# Note: The BadRequestError wrapping is implemented in fact_extraction.py
# but requires a complex integration test setup. The functionality is
# straightforward: when a BadRequestError containing keywords like
# "max_tokens", "max_completion_tokens", or "maximum context" is caught,
# it's wrapped in a ValueError with helpful guidance.
#
# The config validation tests above ensure users get early feedback
# about invalid configurations before runtime errors occur.
+297 -9
View File
@@ -1,5 +1,10 @@
"""
Test LLM provider with different models using actual memory operations.
Test LLM provider with different models using actual Hindsight memory operations.
Tests validate that providers work correctly with:
1. Retain (memory ingestion with fact extraction)
2. Reflect (memory retrieval with tool calling)
3. Mental models (consolidated knowledge generation)
"""
import os
from datetime import datetime
@@ -33,6 +38,12 @@ MODEL_MATRIX = [
# Ollama models (local)
("ollama", "gemma3:12b"),
("ollama", "gemma3:1b"),
# Claude Code (uses Claude Agent SDK with Claude models)
("claude-code", "claude-sonnet-4-20250514"),
# OpenAI Codex (uses MCP with Codex-specific models)
("openai-codex", "gpt-5.2-codex"),
# Mock provider (for testing)
("mock", "mock"),
]
@@ -48,6 +59,171 @@ def get_api_key_for_provider(provider: str) -> str | None:
return os.getenv(env_var) if env_var else None
def should_skip_provider(provider: str, model: str = "") -> tuple[bool, str]:
"""Check if provider should be skipped and return reason."""
# Never skip mock provider
if provider == "mock":
return False, ""
# Skip claude-code and openai-codex in CI (require local auth)
if os.getenv("CI") and provider in ("claude-code", "openai-codex"):
return True, f"{provider} not available in CI (requires local authentication)"
# Skip Ollama in CI (no models available)
if provider == "ollama" and os.getenv("CI"):
return True, "Ollama not available in CI"
# Skip Ollama gemma models (don't support tool calling)
if provider == "ollama" and "gemma" in model.lower():
return True, f"Ollama {model} does not support tool calling"
# Other providers need an API key
if provider not in ("ollama", "claude-code", "openai-codex", "mock"):
api_key = get_api_key_for_provider(provider)
if not api_key:
return True, f"No API key available (set {provider.upper()}_API_KEY)"
return False, ""
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
@pytest.mark.timeout(300) # Increase timeout for slow models like groq gpt-oss-120b
async def test_llm_provider_api_methods(provider: str, model: str):
"""
Test all LLM API methods used by Hindsight at runtime.
This validates that the provider correctly implements the LLMInterface.
Tests:
1. verify_connection() - Connection verification
2. call() with plain text - Basic LLM call
3. call() with response_format - Structured output (used in fact extraction)
4. call_with_tools() - Tool calling (used in reflect agent)
"""
# Skip mock provider - it's a test stub, not a real LLM implementation
if provider == "mock":
pytest.skip("Mock provider is a test stub, not a real LLM")
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
api_key = get_api_key_for_provider(provider)
llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
)
print(f"\n{provider}/{model} - API methods test:")
# Test 1: verify_connection()
try:
await llm.verify_connection()
print(" ✓ verify_connection()")
except Exception as e:
pytest.fail(f"{provider}/{model} verify_connection() failed: {e}")
# Test 2: call() with plain text
try:
response = await llm.call(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is 2+2? Answer in one word."},
],
max_completion_tokens=50,
)
assert response is not None, "call() returned None"
assert len(response) > 0, "call() returned empty string"
print(f" ✓ call() plain text: {response[:50]}")
except Exception as e:
pytest.fail(f"{provider}/{model} call() plain text failed: {e}")
# Test 3: call() with response_format (structured output)
# Skip for models that don't support structured output
skip_structured_output = (provider == "groq" and "gpt-oss-120b" in model.lower())
if skip_structured_output:
print(f" ⊘ call() structured output: skipped (model doesn't support response_format)")
else:
try:
from pydantic import BaseModel
class TestResponse(BaseModel):
answer: str
confidence: str
response = await llm.call(
messages=[
{"role": "system", "content": "You are a math assistant."},
{"role": "user", "content": "What is the capital of France?"},
],
response_format=TestResponse,
max_completion_tokens=100,
)
assert isinstance(response, TestResponse), f"Expected TestResponse, got {type(response)}"
assert hasattr(response, "answer"), "Structured output missing 'answer' field"
assert hasattr(response, "confidence"), "Structured output missing 'confidence' field"
print(f" ✓ call() structured output: answer={response.answer}, confidence={response.confidence}")
except Exception as e:
pytest.fail(f"{provider}/{model} call() structured output failed: {e}")
# Test 4: call_with_tools() (tool calling)
try:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
},
}
]
result = await llm.call_with_tools(
messages=[
{"role": "system", "content": "You are a helpful assistant with access to tools."},
{"role": "user", "content": "What's the weather like in Paris?"},
],
tools=tools,
max_completion_tokens=500, # Increased from 200 to give models enough space for tool calls
)
assert result is not None, "call_with_tools() returned None"
assert hasattr(result, "tool_calls"), "Result missing 'tool_calls' attribute"
# Nano models may hit token limits before making tool calls - that's acceptable
is_nano_model = "nano" in model.lower()
if is_nano_model and len(result.tool_calls) == 0:
# Check if it hit length limit (expected for nano models)
if hasattr(result, "finish_reason") and result.finish_reason == "length":
print(f" ✓ call_with_tools(): nano model hit token limit (expected)")
else:
pytest.fail(f"Nano model made 0 tool calls but didn't hit length limit (finish_reason={getattr(result, 'finish_reason', 'unknown')})")
else:
assert len(result.tool_calls) > 0, f"Expected at least 1 tool call, got {len(result.tool_calls)}"
# Verify tool call structure
tool_call = result.tool_calls[0]
assert hasattr(tool_call, "name"), "Tool call missing 'name'"
assert hasattr(tool_call, "arguments"), "Tool call missing 'arguments'"
assert tool_call.name == "get_weather", f"Expected 'get_weather', got '{tool_call.name}'"
assert "location" in tool_call.arguments, "Tool call arguments missing 'location'"
print(f" ✓ call_with_tools(): {tool_call.name}({tool_call.arguments})")
except Exception as e:
pytest.fail(f"{provider}/{model} call_with_tools() failed: {e}")
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
async def test_llm_provider_memory_operations(provider: str, model: str):
@@ -55,16 +231,16 @@ async def test_llm_provider_memory_operations(provider: str, model: str):
Test LLM provider with actual memory operations: fact extraction and reflect.
All models must pass this test.
"""
# Skip mock provider - it's a test stub, not designed for real operations
if provider == "mock":
pytest.skip("Mock provider is a test stub, not designed for real operations")
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
api_key = get_api_key_for_provider(provider)
# Skip Ollama tests in CI (no models available)
if provider == "ollama" and os.getenv("CI"):
pytest.skip(f"Skipping {provider}/{model}: Ollama not available in CI")
# Other providers need an API key
if provider != "ollama" and not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
llm = LLMProvider(
provider=provider,
api_key=api_key or "",
@@ -122,3 +298,115 @@ async def test_llm_provider_memory_operations(provider: str, model: str):
assert response is not None, f"{provider}/{model} reflect returned None"
assert len(response) > 10, f"{provider}/{model} reflect response too short"
@pytest.mark.parametrize("provider,model", [
("claude-code", "claude-sonnet-4-20250514"),
("openai-codex", "gpt-5.2-codex"),
])
@pytest.mark.asyncio
async def test_llm_provider_consolidation(memory_no_llm_verify, request_context, provider: str, model: str):
"""
Test LLM provider with consolidation (automatic mental model generation from observations).
This validates that the provider can generate synthesized knowledge from raw memories.
This test is limited to claude-code and codex since they're the critical providers
that needed tool calling fixes for reflect and consolidation operations.
"""
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
# Use provider-specific LLM for this test
api_key = get_api_key_for_provider(provider)
memory_no_llm_verify._consolidation_llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
)
# Also need retain LLM for ingesting data
memory_no_llm_verify._retain_llm = memory_no_llm_verify._consolidation_llm
test_bank_id = f"llm_test_consolidation_{provider}_{model}_{datetime.now().timestamp()}"
# Enable observations for this bank
from hindsight_api.config import get_config
config = get_config()
original_value = config.enable_observations
config.enable_observations = True
try:
# Retain memories to consolidate
test_content = """
Bob prefers functional programming with Rust and Haskell.
He emphasizes immutability and pure functions in code reviews.
Bob advocates for type safety and compile-time guarantees.
He avoids mutable state and prefers declarative code patterns.
"""
await memory_no_llm_verify.retain_async(
bank_id=test_bank_id,
content=test_content,
context="Team coding preferences",
event_date=datetime(2024, 12, 1),
request_context=request_context,
)
print(f"\n{provider}/{model} - Consolidation test:")
# Run consolidation to generate observations (mental models)
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=test_bank_id,
request_context=request_context,
)
print(f" Processed: {result.get('memories_processed', 0)} memories")
print(f" Created: {result.get('observations_created', 0)} observations")
print(f" Updated: {result.get('observations_updated', 0)} observations")
# Verify consolidation ran successfully
assert result["status"] in ["success", "no_new_memories"], f"{provider}/{model} consolidation failed"
# If observations were created, verify they contain relevant content
if result.get("observations_created", 0) > 0:
observations = await memory_no_llm_verify.list_mental_models_consolidated(
bank_id=test_bank_id,
request_context=request_context,
)
assert len(observations) > 0, f"{provider}/{model} consolidation created 0 observations"
# Check first observation contains relevant information
obs_content = observations[0].get("content", "").lower()
relevant_terms = ["bob", "functional", "rust", "immutab", "type"]
matches = [term for term in relevant_terms if term in obs_content]
print(f" Observation preview: {observations[0].get('content', '')[:200]}...")
print(f" Found {len(matches)} relevant terms: {matches}")
assert len(matches) >= 2, (
f"{provider}/{model} consolidated observation doesn't contain relevant info. "
f"Expected at least 2 of {relevant_terms}, found {len(matches)}: {matches}"
)
finally:
# Restore original config
config.enable_observations = original_value
# NOTE: The tests above validate the critical Hindsight operations:
#
# test_llm_provider_memory_operations (ALL providers):
# - Fact extraction (retain): tests structured output generation
# - Reflect: tests memory retrieval and reasoning (uses tool calling for claude-code/codex)
#
# test_llm_provider_consolidation (claude-code and codex only):
# - Consolidation: tests automatic mental model generation from observations
# - Requires MemoryEngine fixture with working LLM (from .env or env vars)
# - Run your local LLM server OR set HINDSIGHT_API_LLM_PROVIDER/API_KEY/MODEL env vars
#
# For full end-to-end integration tests using the HTTP API, see tests/test_http_api_integration.py
@@ -0,0 +1,78 @@
"""Integration test for MCP endpoint routing.
This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets.
"""
import httpx
import pytest
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
@pytest.mark.asyncio
async def test_mcp_endpoint_routing_integration(memory):
"""Test that multi-bank and single-bank endpoints expose different tools using StreamableHTTP.
This is a regression test for issue #317 where /mcp/{bank_id}/ was incorrectly
exposing all tools (including list_banks) and bank_id parameters.
"""
from hindsight_api.api import create_app
# Create app with MCP enabled
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
# Use the app's lifespan context to properly initialize MCP servers
async with app.router.lifespan_context(app):
# Create an HTTPX client that routes to our ASGI app
from httpx import ASGITransport
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
# Test 1: Multi-bank endpoint /mcp/
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
multi_result = await session.list_tools()
multi_tools = {t.name for t in multi_result.tools}
# Multi-bank should have all tools including bank management
assert "retain" in multi_tools
assert "recall" in multi_tools
assert "reflect" in multi_tools
assert "list_banks" in multi_tools, "Multi-bank should expose list_banks"
assert "create_bank" in multi_tools, "Multi-bank should expose create_bank"
# Multi-bank retain should have bank_id parameter
retain_tool = next((t for t in multi_result.tools if t.name == "retain"), None)
assert retain_tool is not None
multi_params = set(retain_tool.inputSchema.get("properties", {}).keys())
assert "bank_id" in multi_params, "Multi-bank retain should have bank_id parameter"
# Test 2: Single-bank endpoint /mcp/test-bank/
async with streamable_http_client("http://test/mcp/test-bank/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
single_result = await session.list_tools()
single_tools = {t.name for t in single_result.tools}
# Single-bank should only have scoped tools (no bank management)
assert "retain" in single_tools
assert "recall" in single_tools
assert "reflect" in single_tools
assert "list_banks" not in single_tools, "Single-bank should NOT expose list_banks"
assert "create_bank" not in single_tools, "Single-bank should NOT expose create_bank"
# Single-bank retain should NOT have bank_id parameter
retain_tool = next((t for t in single_result.tools if t.name == "retain"), None)
assert retain_tool is not None
single_params = set(retain_tool.inputSchema.get("properties", {}).keys())
assert "bank_id" not in single_params, "Single-bank retain should NOT have bank_id parameter"
+169
View File
@@ -0,0 +1,169 @@
"""Tests for MCPExtension loading and tool registration."""
from unittest.mock import MagicMock, patch
import pytest
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.extensions.mcp import MCPExtension
class MockMCPExtension(MCPExtension):
"""Test extension that registers a custom tool."""
def __init__(self, config=None):
super().__init__(config)
self.register_tools_called = False
self.registered_mcp = None
self.registered_memory = None
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
"""Register a test tool to verify extension was called."""
self.register_tools_called = True
self.registered_mcp = mcp
self.registered_memory = memory
@mcp.tool()
async def test_extension_tool(query: str) -> str:
"""A test tool registered by the extension."""
return f"Extension tool received: {query}"
class TestMCPExtensionBase:
"""Tests for MCPExtension base class."""
def test_mcp_extension_is_abstract(self):
"""MCPExtension.register_tools is abstract and must be implemented."""
with pytest.raises(TypeError, match="abstract method"):
MCPExtension()
def test_subclass_can_be_instantiated(self):
"""Subclass implementing register_tools can be instantiated."""
ext = MockMCPExtension()
assert ext is not None
assert ext.register_tools_called is False
def test_register_tools_receives_mcp_and_memory(self):
"""register_tools receives FastMCP and MemoryEngine instances."""
ext = MockMCPExtension()
mcp = FastMCP("test")
memory = MagicMock(spec=MemoryEngine)
ext.register_tools(mcp, memory)
assert ext.register_tools_called is True
assert ext.registered_mcp is mcp
assert ext.registered_memory is memory
class TestMCPExtensionLoading:
"""Tests for MCPExtension loading in create_mcp_server."""
@pytest.fixture
def mock_memory(self):
"""Create a mock MemoryEngine."""
memory = MagicMock()
memory._tenant_extension = MagicMock()
memory._tenant_extension.authenticate_mcp = MagicMock()
return memory
def test_create_mcp_server_without_extension(self, mock_memory):
"""create_mcp_server works without MCPExtension configured."""
from hindsight_api.api.mcp import create_mcp_server
with patch("hindsight_api.api.mcp.load_extension", return_value=None):
mcp = create_mcp_server(mock_memory)
# Core tools should be registered
tools = mcp._tool_manager._tools
assert "retain" in tools
assert "recall" in tools
assert "reflect" in tools
# Extension tool should NOT be present
assert "test_extension_tool" not in tools
def test_create_mcp_server_with_extension(self, mock_memory):
"""create_mcp_server loads and calls MCPExtension when configured."""
from hindsight_api.api.mcp import create_mcp_server
mock_ext = MockMCPExtension()
with patch("hindsight_api.api.mcp.load_extension", return_value=mock_ext):
mcp = create_mcp_server(mock_memory)
# Extension should have been called
assert mock_ext.register_tools_called is True
# Core tools should still be registered
tools = mcp._tool_manager._tools
assert "retain" in tools
assert "recall" in tools
# Extension tool should also be registered
assert "test_extension_tool" in tools
@pytest.mark.asyncio
async def test_extension_tool_is_callable(self, mock_memory):
"""Tool registered by extension can be called."""
from hindsight_api.api.mcp import create_mcp_server
mock_ext = MockMCPExtension()
with patch("hindsight_api.api.mcp.load_extension", return_value=mock_ext):
mcp = create_mcp_server(mock_memory)
# Get and call the extension tool
tools = mcp._tool_manager._tools
test_tool = tools["test_extension_tool"]
result = await test_tool.fn(query="hello world")
assert result == "Extension tool received: hello world"
def test_load_extension_called_with_correct_args(self, mock_memory):
"""load_extension is called with 'MCP' prefix and MCPExtension class."""
from hindsight_api.api.mcp import create_mcp_server
with patch("hindsight_api.api.mcp.load_extension") as mock_load:
mock_load.return_value = None
create_mcp_server(mock_memory)
mock_load.assert_called_once_with("MCP", MCPExtension)
class TestMCPExtensionIntegration:
"""Integration tests verifying extension tools work end-to-end."""
@pytest.fixture
def mock_memory(self):
"""Create a mock MemoryEngine with required methods."""
memory = MagicMock()
memory.retain_batch_async = MagicMock()
memory.submit_async_retain = MagicMock(return_value={"operation_id": "test-op"})
memory.recall_async = MagicMock(return_value=MagicMock(results=[]))
memory.reflect_async = MagicMock(return_value=MagicMock(text="reflection"))
memory.list_banks = MagicMock(return_value=[])
memory.get_bank_profile = MagicMock(return_value={"id": "test"})
memory._tenant_extension = MagicMock()
return memory
def test_extension_tools_coexist_with_core_tools(self, mock_memory):
"""Extension tools are added alongside core tools, not replacing them."""
from hindsight_api.api.mcp import create_mcp_server
mock_ext = MockMCPExtension()
with patch("hindsight_api.api.mcp.load_extension", return_value=mock_ext):
mcp = create_mcp_server(mock_memory)
tools = mcp._tool_manager._tools
# All core tools present
assert "retain" in tools
assert "recall" in tools
assert "reflect" in tools
assert "list_banks" in tools
assert "create_bank" in tools
# Extension tool also present
assert "test_extension_tool" in tools
# Total: 5 core + 1 extension = 6 tools
assert len(tools) == 6
+215
View File
@@ -141,3 +141,218 @@ async def test_mcp_tools_propagate_api_key(mock_memory):
finally:
_current_bank_id.reset(bank_token)
_current_api_key.reset(api_key_token)
@pytest.mark.asyncio
async def test_tenant_id_context_variable():
"""Test that tenant_id and api_key_id context variables work correctly."""
from hindsight_api.api.mcp import (
get_current_tenant_id, _current_tenant_id,
get_current_api_key_id, _current_api_key_id,
)
# Initially None
assert get_current_tenant_id() is None
assert get_current_api_key_id() is None
# Set and verify
tenant_token = _current_tenant_id.set("org-123")
key_id_token = _current_api_key_id.set("key-456")
try:
assert get_current_tenant_id() == "org-123"
assert get_current_api_key_id() == "key-456"
finally:
_current_tenant_id.reset(tenant_token)
_current_api_key_id.reset(key_id_token)
# Back to None after reset
assert get_current_tenant_id() is None
assert get_current_api_key_id() is None
@pytest.mark.asyncio
async def test_mcp_tools_propagate_tenant_id_and_api_key_id(mock_memory):
"""Test that MCP tools propagate tenant_id and api_key_id to RequestContext.
This is the critical test for usage metering: the UsageMeteringValidator reads
request_context.tenant_id to identify the org for billing. Without this,
MCP operations get tenant_id="unknown" and billing is skipped entirely.
"""
from hindsight_api.api.mcp import (
create_mcp_server,
_current_bank_id, _current_api_key,
_current_tenant_id, _current_api_key_id,
)
mcp_server = create_mcp_server(mock_memory)
tools = mcp_server._tool_manager._tools
# Set all context vars (simulating what MCPMiddleware does after authenticate_mcp)
bank_token = _current_bank_id.set("test-bank")
api_key_token = _current_api_key.set("hsk_test_key")
tenant_token = _current_tenant_id.set("org-billing-123")
key_id_token = _current_api_key_id.set("key-uuid-456")
try:
retain_tool = tools["retain"]
await retain_tool.fn(content="test content", context="test_context", async_processing=False)
# Verify the RequestContext passed to memory engine has all auth fields
mock_memory.retain_batch_async.assert_called_once()
request_context = mock_memory.retain_batch_async.call_args.kwargs["request_context"]
assert request_context.api_key == "hsk_test_key"
assert request_context.tenant_id == "org-billing-123"
assert request_context.api_key_id == "key-uuid-456"
finally:
_current_bank_id.reset(bank_token)
_current_api_key.reset(api_key_token)
_current_tenant_id.reset(tenant_token)
_current_api_key_id.reset(key_id_token)
def test_multi_bank_mode_exposes_all_tools(mock_memory):
"""Test that multi-bank mode exposes all tools including bank management."""
from hindsight_api.api.mcp import create_mcp_server
# Create server in multi-bank mode (default)
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
tools = mcp_server._tool_manager._tools
# Should have all tools
assert "retain" in tools
assert "recall" in tools
assert "reflect" in tools
assert "list_banks" in tools
assert "create_bank" in tools
def test_single_bank_mode_excludes_bank_management_tools(mock_memory):
"""Test that single-bank mode only exposes bank-scoped tools."""
from hindsight_api.api.mcp import create_mcp_server
# Create server in single-bank mode
mcp_server = create_mcp_server(mock_memory, multi_bank=False)
tools = mcp_server._tool_manager._tools
# Should only have bank-scoped tools
assert "retain" in tools
assert "recall" in tools
assert "reflect" in tools
# Should NOT have bank management tools
assert "list_banks" not in tools
assert "create_bank" not in tools
def test_multi_bank_mode_tools_have_bank_id_param(mock_memory):
"""Test that multi-bank mode tools include bank_id parameter."""
from hindsight_api.api.mcp import create_mcp_server
import inspect
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
tools = mcp_server._tool_manager._tools
# Check that tools have bank_id parameter
retain_tool = tools["retain"]
retain_sig = inspect.signature(retain_tool.fn)
assert "bank_id" in retain_sig.parameters
recall_tool = tools["recall"]
recall_sig = inspect.signature(recall_tool.fn)
assert "bank_id" in recall_sig.parameters
reflect_tool = tools["reflect"]
reflect_sig = inspect.signature(reflect_tool.fn)
assert "bank_id" in reflect_sig.parameters
def test_single_bank_mode_tools_no_bank_id_param(mock_memory):
"""Test that single-bank mode tools do NOT include bank_id parameter."""
from hindsight_api.api.mcp import create_mcp_server
import inspect
mcp_server = create_mcp_server(mock_memory, multi_bank=False)
tools = mcp_server._tool_manager._tools
# Check that tools do NOT have bank_id parameter
retain_tool = tools["retain"]
retain_sig = inspect.signature(retain_tool.fn)
assert "bank_id" not in retain_sig.parameters
recall_tool = tools["recall"]
recall_sig = inspect.signature(recall_tool.fn)
assert "bank_id" not in recall_sig.parameters
reflect_tool = tools["reflect"]
reflect_sig = inspect.signature(reflect_tool.fn)
assert "bank_id" not in reflect_sig.parameters
@pytest.mark.asyncio
async def test_middleware_handles_both_endpoints(mock_memory):
"""Test that MCPMiddleware routes to correct server based on URL path."""
from hindsight_api.api.mcp import MCPMiddleware
# Create middleware (single instance)
middleware = MCPMiddleware(None, mock_memory)
# Verify both server instances exist
assert middleware.multi_bank_app is not None
assert middleware.single_bank_app is not None
# Verify they expose different tools
multi_bank_tools = middleware.multi_bank_server._tool_manager._tools
single_bank_tools = middleware.single_bank_server._tool_manager._tools
# Multi-bank should have all tools
assert "retain" in multi_bank_tools
assert "recall" in multi_bank_tools
assert "list_banks" in multi_bank_tools
assert "create_bank" in multi_bank_tools
# Single-bank should only have scoped tools
assert "retain" in single_bank_tools
assert "recall" in single_bank_tools
assert "list_banks" not in single_bank_tools
assert "create_bank" not in single_bank_tools
@pytest.mark.asyncio
async def test_routing_logic_from_url_path():
"""Test that routing correctly selects server based on URL structure."""
from hindsight_api.api.mcp import MCPMiddleware
from unittest.mock import AsyncMock
# Mock memory
mock_memory = MagicMock()
# Create middleware
middleware = MCPMiddleware(None, mock_memory)
# Simulate different URL patterns and verify routing
test_cases = [
# (path_after_stripping_mcp, expected_bank_id_from_path, expected_bank_id, description)
("/alice/messages", True, "alice", "Bank ID in path with endpoint"),
("/my-agent-123/", True, "my-agent-123", "Bank ID in path with trailing slash"),
("ciccio/messages", True, "ciccio", "Bank ID without leading slash (after mount strip)"),
("bob", True, "bob", "Bank ID only, no leading slash"),
("/messages", False, None, "MCP endpoint, no bank ID"),
("/", False, None, "Root path, no bank ID"),
]
for path, expected_bank_from_path, expected_bank_id, description in test_cases:
# Simulate the path parsing logic with leading slash normalization
if path and not path.startswith("/"):
path = "/" + path
bank_id = None
bank_id_from_path = False
MCP_ENDPOINTS = {"sse", "messages"}
if path.startswith("/") and len(path) > 1:
parts = path[1:].split("/", 1)
if parts[0] and parts[0] not in MCP_ENDPOINTS:
bank_id = parts[0]
bank_id_from_path = True
assert bank_id_from_path == expected_bank_from_path, f"Failed for: {description} (path={path})"
assert bank_id == expected_bank_id, f"Failed bank_id for: {description} (path={path}, got={bank_id})"
+459
View File
@@ -312,6 +312,49 @@ class TestDirectiveTags:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_list_all_directives_without_filter(self, memory: MemoryEngine, request_context):
"""Test that listing directives without tags returns ALL directives (both tagged and untagged)."""
bank_id = f"test-directive-list-all-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create untagged directive
await memory.create_directive(
bank_id=bank_id,
name="Untagged Directive",
content="This has no tags",
request_context=request_context,
)
# Create tagged directive
await memory.create_directive(
bank_id=bank_id,
name="Tagged Directive",
content="This has tags",
tags=["project-x"],
request_context=request_context,
)
# List ALL directives (no tag filter, isolation_mode defaults to False)
all_directives = await memory.list_directives(
bank_id=bank_id,
request_context=request_context,
)
# Should return BOTH tagged and untagged directives
assert len(all_directives) == 2
directive_names = {d["name"] for d in all_directives}
assert "Untagged Directive" in directive_names
assert "Tagged Directive" in directive_names
# Verify the tagged directive has its tags
tagged = next(d for d in all_directives if d["name"] == "Tagged Directive")
assert tagged["tags"] == ["project-x"]
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestReflect:
"""Test reflect endpoint."""
@@ -399,6 +442,161 @@ class TestDirectivesInReflect:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_tagged_directive_not_applied_without_tags(self, memory: MemoryEngine, request_context):
"""Test that directives with tags are NOT applied to untagged reflect operations."""
bank_id = f"test-directive-isolation-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some untagged content
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "The sky is blue."},
{"content": "Water is wet."},
],
request_context=request_context,
)
# Add some tagged content for the project-x context
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "The sky is blue according to project X standards.", "tags": ["project-x"]},
{"content": "Project X color guidelines specify sky is blue.", "tags": ["project-x"]},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Create an untagged directive (should be applied)
await memory.create_directive(
bank_id=bank_id,
name="General Policy",
content="Always be polite and start responses with 'Hello!'",
request_context=request_context,
)
# Create a tagged directive (should NOT be applied to untagged reflect)
await memory.create_directive(
bank_id=bank_id,
name="Tagged Policy",
content="ALWAYS respond in ALL CAPS and end with 'PROJECT-X ONLY'",
tags=["project-x"],
request_context=request_context,
)
# Run reflect without tags - should only apply the untagged directive
result = await memory.reflect_async(
bank_id=bank_id,
query="What color is the sky?",
request_context=request_context,
)
response_lower = result.text.lower()
# Should follow the untagged directive (polite greeting)
assert "hello" in response_lower, f"Expected 'Hello' from untagged directive, but got: {result.text}"
# Should NOT follow the tagged directive (all caps and PROJECT-X)
# If it did follow, the entire response would be in caps
all_caps = result.text.replace(" ", "").replace("!", "").replace(".", "").isupper()
assert not all_caps, f"Tagged directive was incorrectly applied to untagged operation: {result.text}"
assert "project-x only" not in response_lower, f"Tagged directive was incorrectly applied: {result.text}"
# Now run reflect WITH the tag - should apply BOTH directives
result_tagged = await memory.reflect_async(
bank_id=bank_id,
query="What color is the sky?",
tags=["project-x"],
tags_match="all_strict",
request_context=request_context,
)
response_tagged_lower = result_tagged.text.lower()
# With strict matching and tags, should apply the tagged directive
assert "project-x only" in response_tagged_lower, f"Tagged directive should be applied with tags: {result_tagged.text}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_reflect_based_on_structure(self, memory: MemoryEngine, request_context):
"""Test that reflect returns correct based_on structure with directives and memories separated."""
bank_id = f"test-reflect-based-on-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some memories
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice works at Google as a software engineer."},
{"content": "Bob is a product manager at Microsoft."},
{"content": "The team meets every Monday at 9am."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Create a directive
directive = await memory.create_directive(
bank_id=bank_id,
name="Professional Tone",
content="Always maintain a professional and formal tone in responses.",
request_context=request_context,
)
directive_id = directive["id"]
# Run reflect which returns the core result
result = await memory.reflect_async(
bank_id=bank_id,
query="Who works at Google?",
request_context=request_context,
)
# Verify based_on structure exists
assert result.based_on is not None
# Verify directives key exists and contains our directive
assert "directives" in result.based_on
directives_list = result.based_on.get("directives", [])
# Verify directives are dicts with id, name, content (not MemoryFact objects)
assert len(directives_list) > 0, "Should have at least one directive"
directive_found = False
for d in directives_list:
assert isinstance(d, dict), f"Directive should be dict, got {type(d)}"
assert "id" in d, "Directive dict should have 'id'"
assert "name" in d, "Directive dict should have 'name'"
assert "content" in d, "Directive dict should have 'content'"
# Check if this is our directive
if d["id"] == directive_id:
directive_found = True
assert d["name"] == "Professional Tone"
assert "professional" in d["content"].lower()
assert directive_found, f"Our directive {directive_id} should be in based_on.directives"
# Verify memories (world/experience) are separate from directives
has_memories = "world" in result.based_on or "experience" in result.based_on
assert has_memories, "Should have world or experience memories"
# Verify that if mental-models key exists, it's separate from directives
if "mental-models" in result.based_on:
mental_models = result.based_on.get("mental-models", [])
# Verify mental models are MemoryFact objects, not dicts like directives
for mm in mental_models:
assert hasattr(mm, "fact_type"), "Mental model should be MemoryFact with fact_type"
assert mm.fact_type == "mental-models"
assert hasattr(mm, "context")
assert "mental model" in mm.context.lower()
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestDirectivesPromptInjection:
"""Test that directives are properly injected into the system prompt."""
@@ -451,3 +649,264 @@ class TestDirectivesPromptInjection:
directives_pos = prompt.find("## DIRECTIVES")
critical_rules_pos = prompt.find("## CRITICAL RULES")
assert directives_pos < critical_rules_pos
class TestMentalModelRefreshTagSecurity:
"""Test that mental model refresh respects tag-based security boundaries."""
async def test_refresh_with_tags_only_accesses_same_tagged_models(
self, memory: MemoryEngine, request_context
):
"""Test that refreshing a mental model with tags can only access other models with the same tags.
This is a security test to ensure that mental models with tags (e.g., user:alice)
cannot access mental models from other scopes (e.g., user:bob or no tags) during refresh.
"""
bank_id = f"test-refresh-tags-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some facts with different tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice works on the frontend React project. Alice's favorite color is blue.", "tags": ["user:alice"]},
{"content": "Alice prefers working in the morning. Alice drinks coffee every day.", "tags": ["user:alice"]},
{"content": "Bob works on the backend API services. Bob's favorite language is Python.", "tags": ["user:bob"]},
{"content": "Bob prefers working at night. Bob drinks tea every day.", "tags": ["user:bob"]},
{"content": "The company has 100 employees and is growing fast.", "tags": []}, # No tags
],
request_context=request_context,
)
# Wait for background processing
await memory.wait_for_background_tasks()
# Create mental model for user:alice with sensitive data
mm_alice = await memory.create_mental_model(
bank_id=bank_id,
name="Alice's Work Profile",
source_query="What does Alice work on?",
content="Alice is a frontend engineer specializing in React",
tags=["user:alice"],
request_context=request_context,
)
# Create mental model for user:bob with sensitive data
mm_bob = await memory.create_mental_model(
bank_id=bank_id,
name="Bob's Work Profile",
source_query="What does Bob work on?",
content="Bob is a backend engineer specializing in Python",
tags=["user:bob"],
request_context=request_context,
)
# Create mental model with no tags (should not be accessible from tagged models)
mm_untagged = await memory.create_mental_model(
bank_id=bank_id,
name="Company Info",
source_query="What is the company info?",
content="The company has 100 employees",
request_context=request_context,
)
# Create a mental model for user:alice that will be refreshed
mm_alice_refresh = await memory.create_mental_model(
bank_id=bank_id,
name="Alice's Summary",
source_query="What are all the facts about work and preferences?", # Broad query that should match all facts
content="Initial content",
tags=["user:alice"],
request_context=request_context,
)
# Refresh Alice's mental model
refreshed = await memory.refresh_mental_model(
bank_id=bank_id,
mental_model_id=mm_alice_refresh["id"],
request_context=request_context,
)
# SECURITY CHECK: The refreshed content should ONLY include information from
# memories/models tagged with user:alice, NOT from user:bob or untagged
refreshed_content = refreshed["content"].lower()
# Should include Alice's content (either from facts or mental models)
assert "alice" in refreshed_content, \
"Refreshed model should access memories/models with matching tags (user:alice)"
# MUST NOT include Bob's content (security violation)
# Use word boundary matching to avoid false positives (e.g., "team" contains "tea")
import re
def contains_word(text: str, word: str) -> bool:
"""Check if text contains word as a whole word (not substring)."""
return bool(re.search(rf'\b{re.escape(word)}\b', text, re.IGNORECASE))
assert not contains_word(refreshed_content, "bob") and \
not contains_word(refreshed_content, "python") and \
not contains_word(refreshed_content, "tea"), \
f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed['content']}"
# MUST NOT include untagged content (security violation)
assert "100 employees" not in refreshed_content and "growing fast" not in refreshed_content, \
f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed['content']}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_consolidation_only_refreshes_matching_tagged_models(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation only triggers refresh for mental models with matching tags.
This is a security test to ensure that when tagged memories are consolidated,
only mental models with overlapping tags get refreshed, not all mental models.
"""
bank_id = f"test-consolidation-refresh-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create mental models with different tags, all with refresh_after_consolidation=true
mm_alice = await memory.create_mental_model(
bank_id=bank_id,
name="Alice's Model",
source_query="What about Alice?",
content="Initial Alice content",
tags=["user:alice"],
trigger={"refresh_after_consolidation": True},
request_context=request_context,
)
mm_bob = await memory.create_mental_model(
bank_id=bank_id,
name="Bob's Model",
source_query="What about Bob?",
content="Initial Bob content",
tags=["user:bob"],
trigger={"refresh_after_consolidation": True},
request_context=request_context,
)
mm_untagged = await memory.create_mental_model(
bank_id=bank_id,
name="Untagged Model",
source_query="What about general stuff?",
content="Initial untagged content",
trigger={"refresh_after_consolidation": True},
request_context=request_context,
)
# Record initial last_refreshed_at timestamps
alice_initial = mm_alice["last_refreshed_at"]
bob_initial = mm_bob["last_refreshed_at"]
untagged_initial = mm_untagged["last_refreshed_at"]
# Add memories with user:alice tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice likes React", "tags": ["user:alice"]},
{"content": "Alice drinks coffee", "tags": ["user:alice"]},
],
request_context=request_context,
)
# Trigger consolidation manually (this should only refresh Alice's mental model)
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
# Wait for background refresh tasks to complete
await memory.wait_for_background_tasks()
# Check that mental models were refreshed appropriately
mm_alice_after = await memory.get_mental_model(
bank_id, mm_alice["id"], request_context=request_context
)
mm_bob_after = await memory.get_mental_model(
bank_id, mm_bob["id"], request_context=request_context
)
mm_untagged_after = await memory.get_mental_model(
bank_id, mm_untagged["id"], request_context=request_context
)
# SECURITY CHECK: Only Alice's mental model and untagged model should be refreshed
# Alice's model should be refreshed (tags match)
assert mm_alice_after["last_refreshed_at"] != alice_initial or mm_alice_after["content"] != mm_alice["content"], \
"Alice's mental model should be refreshed when user:alice memories are consolidated"
# Bob's model should NOT be refreshed (tags don't match)
assert mm_bob_after["last_refreshed_at"] == bob_initial, \
"SECURITY VIOLATION: Bob's mental model was refreshed even though user:bob memories were not consolidated"
# Untagged model should be refreshed (untagged models are always refreshed)
assert mm_untagged_after["last_refreshed_at"] != untagged_initial or mm_untagged_after["content"] != mm_untagged["content"], \
"Untagged mental model should be refreshed after any consolidation"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_refresh_mental_model_with_directives(self, memory: MemoryEngine, request_context):
"""Test that refreshing a mental model with directives works correctly."""
bank_id = f"test-refresh-directives-{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="Response Style",
content="Always be concise and professional",
request_context=request_context,
)
# Create a concept mental model to refresh
concept = await memory.create_mental_model(
bank_id=bank_id,
name="Team Info",
source_query="Team information summary",
content="Initial team information",
request_context=request_context,
)
# Add some memories
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the team lead and handles project planning."},
{"content": "Bob is a senior engineer who mentors junior developers."},
],
request_context=request_context,
)
# Wait for retain to complete
await memory.wait_for_background_tasks()
# Refresh the concept mental model (this should include directive in based_on)
refreshed = await memory.refresh_mental_model(
bank_id=bank_id,
mental_model_id=concept["id"],
request_context=request_context,
)
# Wait for background tasks to complete
await memory.wait_for_background_tasks()
# Verify the refresh completed without errors
assert refreshed is not None
assert refreshed["content"] is not None
# Get the updated mental model
updated = await memory.get_mental_model(bank_id, concept["id"], request_context=request_context)
assert updated["content"] != "Initial team information"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,45 @@
"""
Test to verify reflect operation creates proper span hierarchy.
"""
import pytest
@pytest.mark.asyncio
async def test_reflect_creates_child_spans(memory, request_context):
"""Test that reflect operation creates child LLM spans."""
from datetime import datetime, timezone
from hindsight_api.tracing import initialize_tracing, get_span_recorder, create_span_recorder
# Initialize tracing with a mock endpoint
initialize_tracing(
service_name="test-hindsight",
endpoint="http://localhost:4318",
deployment_environment="test"
)
# Create span recorder
recorder = create_span_recorder()
bank_id = f"test-reflect-hierarchy-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
context="Geography",
request_context=request_context,
)
# Run reflect
result = await memory.reflect_async(
bank_id=bank_id,
query="What is the capital of France?",
request_context=request_context,
)
print(f"Reflect result: {result.text[:100]}")
print(f"Usage: {result.usage}")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+39
View File
@@ -175,6 +175,45 @@ class TestMentalModelsCRUD:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_create_mental_model_with_custom_id(self, memory: MemoryEngine, request_context):
"""Test creating a mental model with a custom ID."""
bank_id = f"test-mental-model-custom-id-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a mental model with a custom ID
custom_id = "team-communication-preferences"
mental_model = await memory.create_mental_model(
bank_id=bank_id,
mental_model_id=custom_id,
name="Team Communication Preferences",
source_query="How does the team prefer to communicate?",
content="The team prefers async communication via Slack",
tags=["team", "communication"],
request_context=request_context,
)
# Verify the custom ID was used
assert mental_model["id"] == custom_id
assert mental_model["name"] == "Team Communication Preferences"
assert mental_model["tags"] == ["team", "communication"]
# Verify we can retrieve it with the custom ID
fetched = await memory.get_mental_model(
bank_id=bank_id,
mental_model_id=custom_id,
request_context=request_context,
)
assert fetched is not None
assert fetched["id"] == custom_id
assert fetched["name"] == "Team Communication Preferences"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestObservationsAPI:
"""Test observations API endpoints.
+62
View File
@@ -2193,3 +2193,65 @@ If the text contains both Italian and English content, extract ONLY the Italian
# Clear cache again to restore original config
clear_config_cache()
@pytest.mark.asyncio
async def test_retain_batch_with_per_item_tags_on_document(memory, request_context):
"""
Test that per-item tags are correctly stored on documents.
This test verifies the fix for a bug where per-item tags in content dictionaries
were not being merged and passed to document tracking, causing tags to be lost
even though they were correctly sent through the API.
Without the fix, this test would fail because:
- Tags are correctly passed in the content dict
- Tags are correctly stored on memory_units (facts)
- BUT tags were NOT stored on the document record itself
"""
bank_id = f"test_doc_tags_{datetime.now(timezone.utc).timestamp()}"
document_id = "app-state-testuser"
try:
# Retain content with per-item tags (simulating the TasteAI use case)
contents = [
{
"content": '{"username":"testuser","meals":[],"preferences":{"nickname":"testuser"}}',
"document_id": document_id,
"tags": ["user:testuser", "app-type:taste-ai"],
}
]
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
assert len(result) > 0, "Should have retained content"
print(f"\n=== Retained content with tags ===")
# Retrieve the document
doc = await memory.get_document(
document_id=document_id,
bank_id=bank_id,
request_context=request_context,
)
assert doc is not None, "Document should exist"
assert "tags" in doc, "Document should have tags field"
# This is the critical assertion - tags should be stored on the document
doc_tags = doc["tags"] or []
print(f"Document tags: {doc_tags}")
assert "user:testuser" in doc_tags, \
f"Document should have 'user:testuser' tag, but got: {doc_tags}"
assert "app-type:taste-ai" in doc_tags, \
f"Document should have 'app-type:taste-ai' tag, but got: {doc_tags}"
print("✓ Per-item tags correctly stored on document")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
print(f"\n=== Cleaned up bank: {bank_id} ===")
+834
View File
@@ -0,0 +1,834 @@
"""Tests for the Supabase Tenant Extension."""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import jwt as pyjwt
import pytest
from jwt import PyJWK
from hindsight_api.extensions.builtin.supabase_tenant import (
JWKS_CACHE_TTL_SECONDS,
JWKS_MIN_REFRESH_INTERVAL_SECONDS,
MIN_TOKEN_LENGTH,
SupabaseTenantExtension,
)
from hindsight_api.extensions.context import ExtensionContext
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
# A valid UUID for test user IDs
VALID_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
# Minimal JWKS response with one RSA key
MOCK_JWKS_RESPONSE = {
"keys": [
{
"kid": "test-key-1",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB",
}
]
}
def _make_extension(
supabase_url: str = "https://test.supabase.co",
service_key: str | None = "test-service-key",
schema_prefix: str | None = None,
) -> SupabaseTenantExtension:
"""Helper to create a SupabaseTenantExtension with test config."""
config = {
"supabase_url": supabase_url,
}
if service_key is not None:
config["supabase_service_key"] = service_key
if schema_prefix is not None:
config["schema_prefix"] = schema_prefix
return SupabaseTenantExtension(config)
def _make_mock_response(status_code: int = 200, json_data: dict | None = None) -> MagicMock:
"""Helper to create a mock httpx.Response."""
response = MagicMock(spec=httpx.Response)
response.status_code = status_code
response.json.return_value = json_data or {}
response.raise_for_status = MagicMock()
if status_code >= 400:
response.raise_for_status.side_effect = httpx.HTTPStatusError("error", request=MagicMock(), response=response)
return response
def _make_valid_token() -> str:
"""Return a token that passes the MIN_TOKEN_LENGTH check."""
return "a" * (MIN_TOKEN_LENGTH + 10)
def _setup_jwks_ext() -> tuple[SupabaseTenantExtension, AsyncMock]:
"""Create an extension in JWKS mode with mocked internals."""
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
ext._http_client = mock_client
ext._use_jwks = True
ext._jwks_keys = {"test-key-1": MagicMock(spec=PyJWK)}
ext._jwks_keys["test-key-1"].key = "mock-public-key"
ext._jwks_last_fetched = time.monotonic()
return ext, mock_client
def _setup_legacy_ext() -> tuple[SupabaseTenantExtension, AsyncMock]:
"""Create an extension in legacy mode with mocked internals."""
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
ext._http_client = mock_client
ext._use_jwks = False
return ext, mock_client
# ======================================================================
# Initialization
# ======================================================================
class TestSupabaseTenantExtensionInit:
"""Tests for extension initialization."""
def test_init_with_valid_config(self):
ext = _make_extension()
assert ext.supabase_url == "https://test.supabase.co"
assert ext.supabase_service_key == "test-service-key"
assert ext.schema_prefix == "user"
assert ext._initialized_schemas == set()
assert ext._http_client is None
assert ext._use_jwks is False
assert ext._jwks_keys == {}
def test_init_missing_supabase_url(self):
with pytest.raises(ValueError, match="HINDSIGHT_API_TENANT_SUPABASE_URL is required"):
SupabaseTenantExtension({})
def test_init_without_service_key(self):
"""Service key is optional — JWKS mode doesn't require it."""
ext = _make_extension(service_key=None)
assert ext.supabase_service_key is None
def test_init_default_schema_prefix(self):
ext = _make_extension()
assert ext.schema_prefix == "user"
def test_init_custom_schema_prefix(self):
ext = _make_extension(schema_prefix="tenant")
assert ext.schema_prefix == "tenant"
def test_init_strips_trailing_slash(self):
ext = _make_extension(supabase_url="https://test.supabase.co/")
assert ext.supabase_url == "https://test.supabase.co"
def test_init_rejects_invalid_schema_prefix(self):
"""Schema prefix with special characters should be rejected."""
with pytest.raises(ValueError, match="Invalid schema_prefix"):
_make_extension(schema_prefix='"; DROP TABLE')
def test_init_rejects_empty_schema_prefix(self):
with pytest.raises(ValueError, match="Invalid schema_prefix"):
_make_extension(schema_prefix="")
def test_init_rejects_schema_prefix_starting_with_digit(self):
with pytest.raises(ValueError, match="Invalid schema_prefix"):
_make_extension(schema_prefix="123abc")
def test_init_allows_underscore_prefix(self):
ext = _make_extension(schema_prefix="_internal")
assert ext.schema_prefix == "_internal"
def test_is_tenant_extension_subclass(self):
ext = _make_extension()
assert isinstance(ext, TenantExtension)
# ======================================================================
# Startup — JWKS initialization
# ======================================================================
class TestSupabaseTenantExtensionStartup:
"""Tests for on_startup behavior."""
@pytest.mark.asyncio
async def test_on_startup_creates_http_client(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
# JWKS fetch returns keys
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
await ext.on_startup()
assert ext._http_client is mock_client
@pytest.mark.asyncio
async def test_on_startup_fetches_jwks(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK") as mock_pyjwk:
mock_pyjwk.return_value = MagicMock(spec=PyJWK)
await ext.on_startup()
assert ext._use_jwks is True
# First call: JWKS fetch, second call: health check
assert mock_client.get.call_count == 2
jwks_call = mock_client.get.call_args_list[0]
assert jwks_call.args[0] == "https://test.supabase.co/auth/v1/.well-known/jwks.json"
@pytest.mark.asyncio
async def test_on_startup_falls_back_to_legacy_when_jwks_empty(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
# JWKS returns empty keys, health check succeeds
def mock_get(url, **kwargs):
if "jwks" in url:
return _make_mock_response(200, {"keys": []})
return _make_mock_response(200)
mock_client.get.side_effect = mock_get
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
await ext.on_startup()
assert ext._use_jwks is False
@pytest.mark.asyncio
async def test_on_startup_falls_back_to_legacy_when_jwks_fetch_fails(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
call_count = 0
def mock_get(url, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# JWKS fetch fails
raise httpx.ConnectError("Connection refused")
# health check
return _make_mock_response(200)
mock_client.get.side_effect = mock_get
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
await ext.on_startup()
assert ext._use_jwks is False
@pytest.mark.asyncio
async def test_on_startup_raises_if_no_jwks_and_no_service_key(self):
ext = _make_extension(service_key=None)
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, {"keys": []})
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(ValueError, match="HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY is required"):
await ext.on_startup()
@pytest.mark.asyncio
async def test_on_startup_health_check_with_service_key(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
await ext.on_startup()
# Second call should be health check
health_call = mock_client.get.call_args_list[1]
assert health_call.args[0] == "https://test.supabase.co/auth/v1/health"
assert health_call.kwargs["headers"] == {"apikey": "test-service-key"}
@pytest.mark.asyncio
async def test_on_startup_skips_health_check_without_service_key(self):
ext = _make_extension(service_key=None)
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
await ext.on_startup()
# Only one call: JWKS fetch, no health check
assert mock_client.get.call_count == 1
# ======================================================================
# JWKS cache management
# ======================================================================
class TestJWKSCacheManagement:
"""Tests for JWKS key fetching, caching, and rotation handling."""
@pytest.mark.asyncio
async def test_get_signing_key_from_cache(self):
ext, _ = _setup_jwks_ext()
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
key = await ext._get_signing_key("fake-token")
assert key is ext._jwks_keys["test-key-1"]
@pytest.mark.asyncio
async def test_get_signing_key_refreshes_stale_cache(self):
ext, mock_client = _setup_jwks_ext()
# Make cache expired
ext._jwks_last_fetched = time.monotonic() - JWKS_CACHE_TTL_SECONDS - 1
new_key = MagicMock(spec=PyJWK)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK", return_value=new_key),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
key = await ext._get_signing_key("fake-token")
assert key is new_key
mock_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_signing_key_handles_key_rotation(self):
"""When kid not in cache and cache is old enough, refresh once for key rotation."""
ext, mock_client = _setup_jwks_ext()
# Make cache just old enough to allow a refresh
ext._jwks_last_fetched = time.monotonic() - JWKS_MIN_REFRESH_INTERVAL_SECONDS - 1
rotated_key = MagicMock(spec=PyJWK)
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK", return_value=rotated_key),
):
mock_header.return_value = {"kid": "rotated-key-99", "alg": "RS256"}
# The refreshed JWKS won't have "rotated-key-99" either, so this should raise
with pytest.raises(AuthenticationError, match="Unable to find signing key"):
await ext._get_signing_key("fake-token")
# Should have attempted one refresh
mock_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_signing_key_missing_kid_header(self):
ext, _ = _setup_jwks_ext()
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
mock_header.return_value = {"alg": "RS256"} # no kid
with pytest.raises(AuthenticationError, match="Token missing key ID"):
await ext._get_signing_key("fake-token")
@pytest.mark.asyncio
async def test_get_signing_key_refresh_network_error(self):
"""If JWKS refresh fails during key rotation, error should propagate."""
ext, mock_client = _setup_jwks_ext()
ext._jwks_last_fetched = time.monotonic() - JWKS_MIN_REFRESH_INTERVAL_SECONDS - 1
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
mock_header.return_value = {"kid": "unknown-key", "alg": "RS256"}
with pytest.raises(Exception):
await ext._get_signing_key("fake-token")
# ======================================================================
# Authentication — JWKS mode
# ======================================================================
class TestAuthenticateJWKS:
"""Tests for JWKS-based JWT verification."""
@pytest.mark.asyncio
async def test_authenticate_valid_token(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID, "aud": "authenticated"}
result = await ext.authenticate(RequestContext(api_key=_make_valid_token()))
assert isinstance(result, TenantContext)
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert result.schema_name == expected_schema
@pytest.mark.asyncio
async def test_authenticate_custom_prefix(self):
ext, _ = _setup_jwks_ext()
ext.schema_prefix = "org"
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
result = await ext.authenticate(RequestContext(api_key=_make_valid_token()))
assert result.schema_name.startswith("org_")
@pytest.mark.asyncio
async def test_authenticate_expired_token(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.ExpiredSignatureError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Token has expired"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_invalid_audience(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.InvalidAudienceError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Invalid token audience"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_invalid_issuer(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.InvalidIssuerError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Invalid token issuer"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_decode_error(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=pyjwt.DecodeError(),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Invalid token"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_missing_sub_claim(self):
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"email": "[email protected]"} # no sub
with pytest.raises(AuthenticationError, match="missing subject"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_empty_sub_claim(self):
"""Empty string sub claim should be treated as missing."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": ""}
with pytest.raises(AuthenticationError, match="missing subject"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_generic_exception(self):
"""Unexpected exceptions during decode should be caught and wrapped."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch(
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
side_effect=RuntimeError("unexpected internal error"),
),
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
with pytest.raises(AuthenticationError, match="Token verification failed"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# ======================================================================
# Authentication — Legacy mode
# ======================================================================
class TestAuthenticateLegacy:
"""Tests for legacy /auth/v1/user endpoint verification."""
@pytest.mark.asyncio
async def test_authenticate_valid_token(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(200, {"id": VALID_UUID})
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
result = await ext.authenticate(RequestContext(api_key=_make_valid_token()))
assert isinstance(result, TenantContext)
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert result.schema_name == expected_schema
@pytest.mark.asyncio
async def test_authenticate_calls_user_endpoint(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(200, {"id": VALID_UUID})
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
token = _make_valid_token()
await ext.authenticate(RequestContext(api_key=token))
mock_client.get.assert_called_once_with(
"https://test.supabase.co/auth/v1/user",
headers={
"Authorization": f"Bearer {token}",
"apikey": "test-service-key",
},
)
@pytest.mark.asyncio
async def test_authenticate_expired_token_401(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(401)
with pytest.raises(AuthenticationError, match="Invalid or expired token"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_supabase_error_500(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(500)
with pytest.raises(AuthenticationError, match="Authentication failed: 500"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_no_user_id(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.return_value = _make_mock_response(200, {"email": "[email protected]"})
with pytest.raises(AuthenticationError, match="no user ID found"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_timeout(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.side_effect = httpx.TimeoutException("Request timed out")
with pytest.raises(AuthenticationError, match="Authentication timeout"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_connection_error(self):
ext, mock_client = _setup_legacy_ext()
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
with pytest.raises(AuthenticationError, match="Connection error"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# ======================================================================
# Authentication — common (both modes)
# ======================================================================
class TestAuthenticateCommon:
"""Tests that apply regardless of verification mode."""
@pytest.mark.asyncio
async def test_authenticate_missing_token(self):
ext, _ = _setup_jwks_ext()
with pytest.raises(AuthenticationError, match="Missing Authorization header"):
await ext.authenticate(RequestContext(api_key=None))
@pytest.mark.asyncio
async def test_authenticate_empty_token(self):
ext, _ = _setup_jwks_ext()
with pytest.raises(AuthenticationError, match="Missing Authorization header"):
await ext.authenticate(RequestContext(api_key=""))
@pytest.mark.asyncio
async def test_authenticate_short_token(self):
ext, _ = _setup_jwks_ext()
with pytest.raises(AuthenticationError, match="Invalid token format"):
await ext.authenticate(RequestContext(api_key="short"))
@pytest.mark.asyncio
async def test_authenticate_not_initialized(self):
ext = _make_extension()
# _http_client is None by default
with pytest.raises(AuthenticationError, match="Extension not initialized"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_rejects_non_uuid_user_id(self):
"""User IDs that aren't valid UUIDs should be rejected for schema safety."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": "not-a-uuid"}
with pytest.raises(AuthenticationError, match="Invalid user ID format"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
@pytest.mark.asyncio
async def test_authenticate_rejects_malicious_user_id(self):
"""User IDs with SQL injection attempts should be rejected."""
ext, _ = _setup_jwks_ext()
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": "'; DROP TABLE users;--"}
with pytest.raises(AuthenticationError, match="Invalid user ID format"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# ======================================================================
# Schema management
# ======================================================================
class TestSupabaseTenantExtensionSchemaManagement:
"""Tests for schema initialization and caching."""
@pytest.mark.asyncio
async def test_schema_initialized_on_first_access(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
expected_schema = "user_" + VALID_UUID.replace("-", "_")
mock_context.run_migration.assert_called_once_with(expected_schema)
assert expected_schema in ext._initialized_schemas
@pytest.mark.asyncio
async def test_schema_cached_on_second_access(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# run_migration should only be called once
expected_schema = "user_" + VALID_UUID.replace("-", "_")
mock_context.run_migration.assert_called_once_with(expected_schema)
@pytest.mark.asyncio
async def test_schema_init_failure(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock(side_effect=RuntimeError("Migration failed"))
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
with pytest.raises(AuthenticationError, match="Failed to initialize tenant"):
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
# Schema should NOT be cached on failure
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert expected_schema not in ext._initialized_schemas
# ======================================================================
# List tenants
# ======================================================================
class TestSupabaseTenantExtensionListTenants:
"""Tests for list_tenants behavior."""
@pytest.mark.asyncio
async def test_list_tenants_empty(self):
ext = _make_extension()
tenants = await ext.list_tenants()
assert tenants == []
@pytest.mark.asyncio
async def test_list_tenants_after_auth(self):
ext, _ = _setup_jwks_ext()
mock_context = AsyncMock(spec=ExtensionContext)
mock_context.run_migration = AsyncMock()
ext._context = mock_context
with (
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
):
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
mock_decode.return_value = {"sub": VALID_UUID}
await ext.authenticate(RequestContext(api_key=_make_valid_token()))
tenants = await ext.list_tenants()
assert len(tenants) == 1
assert isinstance(tenants[0], Tenant)
expected_schema = "user_" + VALID_UUID.replace("-", "_")
assert tenants[0].schema == expected_schema
# ======================================================================
# Shutdown
# ======================================================================
class TestSupabaseTenantExtensionShutdown:
"""Tests for on_shutdown behavior."""
@pytest.mark.asyncio
async def test_on_shutdown_closes_client(self):
ext = _make_extension()
mock_client = AsyncMock(spec=httpx.AsyncClient)
ext._http_client = mock_client
await ext.on_shutdown()
mock_client.aclose.assert_called_once()
assert ext._http_client is None
@pytest.mark.asyncio
async def test_on_shutdown_no_client(self):
ext = _make_extension()
# _http_client is None by default — should not raise
await ext.on_shutdown()
# ======================================================================
# Extension loader integration
# ======================================================================
class TestSupabaseTenantExtensionLoader:
"""Tests for loading via the extension loader."""
def test_load_via_extension_loader(self, monkeypatch):
monkeypatch.setenv(
"HINDSIGHT_API_TENANT_EXTENSION",
"hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension",
)
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_URL", "https://test.supabase.co")
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY", "test-key")
monkeypatch.setenv("HINDSIGHT_API_TENANT_SCHEMA_PREFIX", "custom")
ext = load_extension("TENANT", TenantExtension)
assert ext is not None
assert isinstance(ext, SupabaseTenantExtension)
assert ext.supabase_url == "https://test.supabase.co"
assert ext.supabase_service_key == "test-key"
assert ext.schema_prefix == "custom"
def test_load_without_service_key(self, monkeypatch):
"""Extension should load without service key — JWKS mode doesn't need it."""
monkeypatch.setenv(
"HINDSIGHT_API_TENANT_EXTENSION",
"hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension",
)
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_URL", "https://test.supabase.co")
monkeypatch.delenv("HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY", raising=False)
ext = load_extension("TENANT", TenantExtension)
assert ext is not None
assert isinstance(ext, SupabaseTenantExtension)
assert ext.supabase_service_key is None
+407
View File
@@ -0,0 +1,407 @@
"""
Unit tests for OpenTelemetry tracing instrumentation.
Tests the tracing module's ability to record LLM calls with GenAI semantic conventions.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.tracing import (
PROVIDER_NAME_MAPPING,
GenAIAttributes,
LLMSpanRecorder,
NoOpLLMSpanRecorder,
_truncate_content,
create_operation_span,
initialize_tracing,
is_tracing_enabled,
)
def test_provider_name_mapping():
"""Test that provider names are correctly mapped to GenAI conventions."""
assert PROVIDER_NAME_MAPPING["openai"] == "openai"
assert PROVIDER_NAME_MAPPING["anthropic"] == "anthropic"
assert PROVIDER_NAME_MAPPING["gemini"] == "google"
assert PROVIDER_NAME_MAPPING["vertexai"] == "google"
assert PROVIDER_NAME_MAPPING["groq"] == "groq"
assert PROVIDER_NAME_MAPPING["ollama"] == "ollama"
assert PROVIDER_NAME_MAPPING["openai-codex"] == "openai"
assert PROVIDER_NAME_MAPPING["claude-code"] == "anthropic"
def test_truncate_content_short():
"""Test that short content is not truncated."""
content = "This is a short message"
result = _truncate_content(content)
assert result == content
def test_truncate_content_long():
"""Test that long content is truncated."""
content = "x" * 150000 # Exceeds MAX_CONTENT_LENGTH
result = _truncate_content(content)
assert len(result) < len(content)
assert "[TRUNCATED:" in result
assert result.startswith("x" * 100)
def test_noop_span_recorder():
"""Test that NoOpLLMSpanRecorder doesn't raise errors."""
recorder = NoOpLLMSpanRecorder()
# Should not raise any errors
recorder.record_llm_call(
provider="openai",
model="gpt-4",
scope="test",
messages=[{"role": "user", "content": "test"}],
response_content="test response",
input_tokens=10,
output_tokens=5,
duration=1.0,
)
def test_llm_span_recorder_format_messages():
"""Test message formatting to GenAI convention."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
messages = [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "Hello"},
]
result = recorder._format_messages(messages)
parsed = json.loads(result)
assert len(parsed) == 2
assert parsed[0]["role"] == "system"
assert parsed[0]["content"] == "You are helpful"
assert parsed[1]["role"] == "user"
assert parsed[1]["content"] == "Hello"
def test_llm_span_recorder_format_output():
"""Test output formatting to GenAI convention."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
result = recorder._format_output("Hello world", "stop")
parsed = json.loads(result)
assert len(parsed) == 1
assert parsed[0]["role"] == "assistant"
assert parsed[0]["content"] == "Hello world"
def test_llm_span_recorder_format_output_none():
"""Test output formatting with None content."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
result = recorder._format_output(None, None)
parsed = json.loads(result)
assert parsed == []
def test_llm_span_recorder_extract_system_instructions():
"""Test system instruction extraction."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
messages = [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "Hello"},
]
result = recorder._extract_system_instructions(messages)
assert result == "You are helpful"
def test_llm_span_recorder_extract_system_instructions_none():
"""Test system instruction extraction with no system message."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
messages = [
{"role": "user", "content": "Hello"},
]
result = recorder._extract_system_instructions(messages)
assert result is None
@patch("hindsight_api.tracing.time")
def test_llm_span_recorder_record_success(mock_time):
"""Test successful LLM call recording."""
# Mock time
mock_time.time_ns.return_value = 1000000000000 # 1 second in nanoseconds
# Create mock tracer and span
mock_span = MagicMock()
mock_tracer = MagicMock()
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
recorder = LLMSpanRecorder(mock_tracer)
messages = [{"role": "user", "content": "Hello"}]
response_content = "Hi there!"
recorder.record_llm_call(
provider="openai",
model="gpt-4",
scope="test",
messages=messages,
response_content=response_content,
input_tokens=10,
output_tokens=5,
duration=1.5,
finish_reason="stop",
error=None,
)
# Verify span was created with correct name (hindsight.{scope})
mock_tracer.start_as_current_span.assert_called_once()
call_args = mock_tracer.start_as_current_span.call_args
assert call_args[0][0] == "hindsight.test"
# Verify attributes were set
assert mock_span.set_attribute.called
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
assert attribute_calls[GenAIAttributes.OPERATION_NAME] == "chat"
assert attribute_calls[GenAIAttributes.PROVIDER_NAME] == "openai"
assert attribute_calls[GenAIAttributes.REQUEST_MODEL] == "gpt-4"
assert attribute_calls[GenAIAttributes.RESPONSE_MODEL] == "gpt-4"
assert attribute_calls[GenAIAttributes.USAGE_INPUT_TOKENS] == 10
assert attribute_calls[GenAIAttributes.USAGE_OUTPUT_TOKENS] == 5
assert attribute_calls["hindsight.scope"] == "test"
# Verify event was added
mock_span.add_event.assert_called_once()
event_call = mock_span.add_event.call_args
assert event_call[0][0] == "gen_ai.client.inference.operation.details"
# Verify status was set to OK
mock_span.set_status.assert_called()
# Verify span was ended
mock_span.end.assert_called_once()
@patch("hindsight_api.tracing.time")
def test_llm_span_recorder_record_error(mock_time):
"""Test error LLM call recording."""
# Mock time
mock_time.time_ns.return_value = 1000000000000
# Create mock tracer and span
mock_span = MagicMock()
mock_tracer = MagicMock()
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
recorder = LLMSpanRecorder(mock_tracer)
messages = [{"role": "user", "content": "Hello"}]
error = ValueError("Test error")
recorder.record_llm_call(
provider="anthropic",
model="claude-3",
scope="test",
messages=messages,
response_content=None,
input_tokens=10,
output_tokens=0,
duration=0.5,
finish_reason=None,
error=error,
)
# Verify error status was set
mock_span.set_status.assert_called()
status_call = mock_span.set_status.call_args[0][0]
assert status_call.status_code.name == "ERROR"
# Verify error type attribute was set
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
assert attribute_calls[GenAIAttributes.ERROR_TYPE] == "ValueError"
# Verify exception was recorded
mock_span.record_exception.assert_called_once_with(error)
@patch("hindsight_api.tracing.time")
def test_llm_span_recorder_provider_mapping(mock_time):
"""Test that provider names are mapped correctly."""
mock_time.time_ns.return_value = 1000000000000
mock_span = MagicMock()
mock_tracer = MagicMock()
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
recorder = LLMSpanRecorder(mock_tracer)
# Test gemini -> google mapping
recorder.record_llm_call(
provider="gemini",
model="gemini-pro",
scope="test",
messages=[{"role": "user", "content": "test"}],
response_content="test",
input_tokens=5,
output_tokens=3,
duration=1.0,
)
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
assert attribute_calls[GenAIAttributes.PROVIDER_NAME] == "google"
# ==================== Parent Span Tests ====================
def test_create_operation_span_disabled():
"""Test that create_operation_span returns no-op when tracing is disabled."""
# Tracing should be disabled by default
assert not is_tracing_enabled()
# Should return a no-op context manager
span = create_operation_span("test_operation", "test_bank_id")
# Should be usable as context manager without errors
with span:
pass
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_create_operation_span_enabled(mock_tracer):
"""Test that create_operation_span creates a span when tracing is enabled."""
# Mock the tracer
mock_span = MagicMock()
mock_tracer.start_as_current_span.return_value = mock_span
# Create operation span
span = create_operation_span("retain", "bank123")
# Verify span was created with correct name
mock_tracer.start_as_current_span.assert_called_once_with("hindsight.retain")
# Verify attributes were set
mock_span.set_attribute.assert_any_call("hindsight.operation", "retain")
mock_span.set_attribute.assert_any_call("hindsight.bank_id", "bank123")
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_create_operation_span_no_bank_id(mock_tracer):
"""Test that create_operation_span works without bank_id."""
mock_span = MagicMock()
mock_tracer.start_as_current_span.return_value = mock_span
# Create operation span without bank_id
span = create_operation_span("consolidation")
# Verify span was created
mock_tracer.start_as_current_span.assert_called_once_with("hindsight.consolidation")
# Verify only operation attribute was set (not bank_id)
assert mock_span.set_attribute.call_count == 1
mock_span.set_attribute.assert_called_once_with("hindsight.operation", "consolidation")
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_create_operation_span_all_operations(mock_tracer):
"""Test that all 4 operations can create parent spans."""
mock_span = MagicMock()
mock_tracer.start_as_current_span.return_value = mock_span
operations = ["retain", "consolidation", "reflect", "mental_model_refresh"]
for operation in operations:
mock_tracer.reset_mock()
mock_span.reset_mock()
span = create_operation_span(operation, "test_bank")
# Verify span was created with correct name
mock_tracer.start_as_current_span.assert_called_once_with(f"hindsight.{operation}")
# Verify attributes
mock_span.set_attribute.assert_any_call("hindsight.operation", operation)
mock_span.set_attribute.assert_any_call("hindsight.bank_id", "test_bank")
@patch("hindsight_api.tracing.time")
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_parent_child_span_hierarchy(mock_tracer, mock_time):
"""Test that child LLM spans are created under parent operation spans."""
mock_time.time_ns.return_value = 1000000000000
# Create mock parent span
mock_parent_span = MagicMock()
mock_parent_span.__enter__ = MagicMock(return_value=mock_parent_span)
mock_parent_span.__exit__ = MagicMock(return_value=False)
# Create mock child span
mock_child_span = MagicMock()
# Mock tracer to return parent span first, then child span
mock_tracer.start_as_current_span.side_effect = [
mock_parent_span, # Parent span
MagicMock(__enter__=MagicMock(return_value=mock_child_span), __exit__=MagicMock(return_value=False)), # Child
]
# Create parent operation span
with create_operation_span("retain", "bank123"):
# Simulate creating a child LLM span
recorder = LLMSpanRecorder(mock_tracer)
recorder.record_llm_call(
provider="openai",
model="gpt-4",
scope="retain_extract_facts",
messages=[{"role": "user", "content": "test"}],
response_content="response",
input_tokens=10,
output_tokens=5,
duration=1.0,
)
# Verify both parent and child spans were created
assert mock_tracer.start_as_current_span.call_count == 2
# Verify parent span was created first
first_call = mock_tracer.start_as_current_span.call_args_list[0]
assert first_call[0][0] == "hindsight.retain"
# Verify child span was created second (hindsight.{scope})
second_call = mock_tracer.start_as_current_span.call_args_list[1]
assert second_call[0][0] == "hindsight.retain_extract_facts"
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_operation_span_context_manager(mock_tracer):
"""Test that operation spans work as context managers."""
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_tracer.start_as_current_span.return_value = mock_span
# Use span as context manager
with create_operation_span("reflect", "bank456"):
# Do some work
pass
# Verify span lifecycle
mock_tracer.start_as_current_span.assert_called_once()
mock_span.__enter__.assert_called_once()
mock_span.__exit__.assert_called_once()
@@ -0,0 +1,196 @@
"""
Integration tests for OpenTelemetry tracing with memory engine operations.
Tests that parent spans are correctly created for retain, consolidation, reflect,
and mental_model_refresh operations.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_retain_creates_parent_span(mock_create_span, memory, request_context):
"""Test that retain operation creates a parent span."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-retain-{datetime.now(timezone.utc).timestamp()}"
try:
# Execute retain (automatically creates bank if needed)
await memory.retain_async(
bank_id=bank_id,
content="Test memory for tracing",
context="Test context",
request_context=request_context,
)
# Verify parent span was created
mock_create_span.assert_called()
call_args = mock_create_span.call_args
assert call_args[0][0] == "retain" # operation name
assert call_args[0][1] == bank_id # bank_id
# Verify span was used as context manager
mock_span.__enter__.assert_called()
mock_span.__exit__.assert_called()
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_consolidation_creates_parent_span(mock_create_span, memory, request_context):
"""Test that consolidation operation creates a parent span."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-consolidation-{datetime.now(timezone.utc).timestamp()}"
try:
# Execute consolidation (bank will be created automatically)
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
# Verify parent span was created
mock_create_span.assert_called()
call_args = mock_create_span.call_args
assert call_args[0][0] == "consolidation"
assert call_args[0][1] == bank_id
# Verify span was used as context manager
mock_span.__enter__.assert_called()
mock_span.__exit__.assert_called()
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_reflect_creates_parent_span(mock_create_span, memory, request_context):
"""Test that reflect operation creates a parent span."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-reflect-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories first
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
context="Geography fact",
request_context=request_context,
)
# Reset mock to clear retain call
mock_create_span.reset_mock()
# Execute reflect
await memory.reflect_async(
bank_id=bank_id,
query="What is the capital of France?",
request_context=request_context,
)
# Verify parent span was created
mock_create_span.assert_called()
call_args = mock_create_span.call_args
assert call_args[0][0] == "reflect"
assert call_args[0][1] == bank_id
# Verify span was used as context manager
mock_span.__enter__.assert_called()
mock_span.__exit__.assert_called()
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_retain_batch_creates_single_parent_span(mock_create_span, memory, request_context):
"""Test that batch retain creates one parent span for the entire batch."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-batch-{datetime.now(timezone.utc).timestamp()}"
try:
# Execute batch retain with multiple items
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Memory 1", "context": "Context 1"},
{"content": "Memory 2", "context": "Context 2"},
{"content": "Memory 3", "context": "Context 3"},
],
request_context=request_context,
)
# Verify parent span was created only once for the entire batch
assert mock_create_span.call_count == 1
call_args = mock_create_span.call_args
assert call_args[0][0] == "retain"
assert call_args[0][1] == bank_id
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.tracing._tracing_enabled", False)
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_operations_work_when_tracing_disabled(mock_create_span, memory, request_context):
"""Test that operations work correctly when tracing is disabled."""
# Setup - create_operation_span should return a no-op context manager
from contextlib import nullcontext
mock_create_span.return_value = nullcontext()
bank_id = f"test-no-trace-{datetime.now(timezone.utc).timestamp()}"
try:
# All operations should work without errors
await memory.retain_async(
bank_id=bank_id,
content="Test memory",
request_context=request_context,
)
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
await memory.reflect_async(
bank_id=bank_id,
query="Test query",
request_context=request_context,
)
# Verify no errors occurred and spans were attempted to be created
assert mock_create_span.call_count >= 3
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,273 @@
"""
Comprehensive tracing span verification tests.
Verifies that all memory engine operations create correct parent and child spans
with proper attributes and hierarchy.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.asyncio
@pytest.mark.skip(reason="Background consolidation causes StopIteration - need to investigate separately")
@patch("hindsight_api.tracing._tracing_enabled", True)
@patch("hindsight_api.tracing._tracer")
async def test_recall_span_hierarchy(mock_tracer, memory, request_context):
"""Test that recall creates proper parent and child spans."""
# Setup mock spans
mock_recall_span = MagicMock()
mock_recall_span.__enter__ = MagicMock(return_value=mock_recall_span)
mock_recall_span.__exit__ = MagicMock(return_value=False)
mock_embedding_span = MagicMock()
mock_retrieval_span = MagicMock()
mock_fusion_span = MagicMock()
mock_rerank_span = MagicMock()
# Mock tracer to return spans in sequence
mock_tracer.start_as_current_span.side_effect = [mock_recall_span]
mock_tracer.start_span.side_effect = [
mock_embedding_span,
mock_retrieval_span,
mock_fusion_span,
mock_rerank_span,
]
bank_id = f"test-recall-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories first
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
request_context=request_context,
)
# Wait a bit for any background tasks to settle
import asyncio
await asyncio.sleep(0.5)
# Reset mocks after retain
mock_tracer.reset_mock()
mock_recall_span.reset_mock()
# Execute recall
await memory.recall_async(
bank_id=bank_id,
query="What is the capital of France?",
request_context=request_context,
)
# Verify parent span was created with start_as_current_span
assert mock_tracer.start_as_current_span.called
parent_call = mock_tracer.start_as_current_span.call_args
assert parent_call[0][0] == "hindsight.recall"
# Verify parent span attributes were set
recall_attrs = {call[0][0]: call[0][1] for call in mock_recall_span.set_attribute.call_args_list}
assert "hindsight.bank_id" in recall_attrs
assert recall_attrs["hindsight.bank_id"] == bank_id
assert "hindsight.query" in recall_attrs
assert "hindsight.fact_types" in recall_attrs
assert "hindsight.thinking_budget" in recall_attrs
assert "hindsight.max_tokens" in recall_attrs
# Verify child spans were created (if tracing is enabled)
if mock_tracer.start_span.called:
child_spans = [call[0][0] for call in mock_tracer.start_span.call_args_list]
assert "hindsight.recall_embedding" in child_spans
assert "hindsight.recall_retrieval" in child_spans
assert "hindsight.recall_fusion" in child_spans
assert "hindsight.recall_rerank" in child_spans
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_mental_model_refresh_span_exists(memory, request_context):
"""Test that mental model refresh functionality exists (span creation tested via unit tests)."""
# This test verifies that refresh_mental_model method exists and can be called
# The actual span creation is tested in unit tests with proper mocking
bank_id = f"test-mmr-{datetime.now(timezone.utc).timestamp()}"
try:
# Just verify the method exists - it will return None if no mental model found
result = await memory.refresh_mental_model(
bank_id=bank_id,
mental_model_id="non-existent-id",
request_context=request_context,
)
# Result will be None since mental model doesn't exist
assert result is None
finally:
# Cleanup
try:
await memory.delete_bank(bank_id, request_context=request_context)
except Exception:
pass
@pytest.mark.asyncio
async def test_consolidation_child_spans(memory, request_context):
"""Test that consolidation creates child spans for its operations."""
bank_id = f"test-cons-child-{datetime.now(timezone.utc).timestamp()}"
try:
# Add memories to consolidate
await memory.retain_async(
bank_id=bank_id,
content="The Eiffel Tower is in Paris",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
request_context=request_context,
)
# Run consolidation (this will create parent + child spans)
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
# Note: We can't easily verify the child spans without mocking the tracer,
# but we can verify that consolidation completes successfully
# The actual span creation is tested in unit tests
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_reflect_tool_call_spans(memory, request_context):
"""Test that reflect creates tool call spans (not reflect_generation)."""
bank_id = f"test-reflect-tools-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories
await memory.retain_async(
bank_id=bank_id,
content="Machine learning is a subset of AI",
request_context=request_context,
)
# Execute reflect (will create reflect_tool_call spans)
result = await memory.reflect_async(
bank_id=bank_id,
query="What is machine learning?",
request_context=request_context,
)
# Verify reflect completed successfully
assert result.text
assert len(result.text) > 0
# The span names are verified via unit tests with mocked tracers
# This integration test ensures the operation completes successfully
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_all_operations_create_spans(memory, request_context):
"""Comprehensive test that all operations create their respective spans."""
bank_id = f"test-all-ops-{datetime.now(timezone.utc).timestamp()}"
try:
# 1. Retain operation
await memory.retain_async(
bank_id=bank_id,
content="Test memory for comprehensive span test",
request_context=request_context,
)
# 2. Recall operation
await memory.recall_async(
bank_id=bank_id,
query="test memory",
request_context=request_context,
)
# 3. Reflect operation
await memory.reflect_async(
bank_id=bank_id,
query="What can you tell me about the test?",
request_context=request_context,
)
# 4. Consolidation operation
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
# All operations completed successfully
# Span hierarchy verification is done in unit tests with mocked tracers
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.tracing._tracing_enabled", True)
@patch("hindsight_api.tracing._tracer")
async def test_recall_span_attributes(mock_tracer, memory, request_context):
"""Verify that recall spans have all required attributes."""
# Setup mock span
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_tracer.start_as_current_span.return_value = mock_span
bank_id = f"test-attrs-{datetime.now(timezone.utc).timestamp()}"
try:
# Add memory
await memory.retain_async(
bank_id=bank_id,
content="Test content for attributes",
request_context=request_context,
)
# Reset mock
mock_span.reset_mock()
# Execute recall with specific parameters
await memory.recall_async(
bank_id=bank_id,
query="test query for attributes",
fact_type=["world", "experience"],
max_tokens=2048,
request_context=request_context,
)
# Collect all attributes set on the span
attrs = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
# Verify required attributes
assert "hindsight.bank_id" in attrs
assert "hindsight.query" in attrs
assert "hindsight.fact_types" in attrs
assert "hindsight.max_tokens" in attrs
assert "hindsight.thinking_budget" in attrs
# Verify attribute values
assert attrs["hindsight.bank_id"] == bank_id
assert "test query" in attrs["hindsight.query"]
assert attrs["hindsight.max_tokens"] == 2048
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.7"
version = "0.4.10"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
@@ -98,6 +98,7 @@ pub fn create(
bank_id: &str,
name: &str,
source_query: &str,
id: Option<&str>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -108,6 +109,7 @@ pub fn create(
};
let request = types::CreateMentalModelRequest {
id: id.map(|s| s.to_string()),
name: name.to_string(),
source_query: source_query.to_string(),
max_tokens: 2048,
+6 -2
View File
@@ -596,6 +596,10 @@ enum MentalModelCommands {
/// Source query to generate the mental model from
source_query: String,
/// Optional custom ID for the mental model (alphanumeric lowercase with hyphens)
#[arg(long)]
id: Option<String>,
},
/// Update a mental model
@@ -863,8 +867,8 @@ fn run() -> Result<()> {
MentalModelCommands::Get { bank_id, mental_model_id } => {
commands::mental_model::get(&client, &bank_id, &mental_model_id, verbose, output_format)
}
MentalModelCommands::Create { bank_id, name, source_query } => {
commands::mental_model::create(&client, &bank_id, &name, &source_query, verbose, output_format)
MentalModelCommands::Create { bank_id, name, source_query, id } => {
commands::mental_model::create(&client, &bank_id, &name, &source_query, id.as_deref(), verbose, output_format)
}
MentalModelCommands::Update { bank_id, mental_model_id, name } => {
commands::mental_model::update(&client, &bank_id, &mental_model_id, name, verbose, output_format)
@@ -7,7 +7,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -489,7 +489,7 @@ class Configuration:
return "Python SDK Debug Report:\n"\
"OS: {env}\n"\
"Python Version: {pyversion}\n"\
"Version of the API: 0.4.7\n"\
"Version of the API: 0.4.10\n"\
"SDK Package Version: 0.0.7".\
format(env=sys.platform, pyversion=sys.version)
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -6,7 +6,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.7
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -28,12 +28,13 @@ class CreateMentalModelRequest(BaseModel):
"""
Request model for creating a mental model.
""" # noqa: E501
id: Optional[StrictStr] = None
name: StrictStr = Field(description="Human-readable name for the mental model")
source_query: StrictStr = Field(description="The query to run to generate content")
tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility")
max_tokens: Optional[Annotated[int, Field(le=8192, strict=True, ge=256)]] = Field(default=2048, description="Maximum tokens for generated content")
trigger: Optional[MentalModelTrigger] = Field(default=None, description="Trigger settings")
__properties: ClassVar[List[str]] = ["name", "source_query", "tags", "max_tokens", "trigger"]
__properties: ClassVar[List[str]] = ["id", "name", "source_query", "tags", "max_tokens", "trigger"]
model_config = ConfigDict(
populate_by_name=True,
@@ -77,6 +78,11 @@ class CreateMentalModelRequest(BaseModel):
# override the default output from pydantic by calling `to_dict()` of trigger
if self.trigger:
_dict['trigger'] = self.trigger.to_dict()
# set to None if id (nullable) is None
# and model_fields_set contains the field
if self.id is None and "id" in self.model_fields_set:
_dict['id'] = None
return _dict
@classmethod
@@ -89,6 +95,7 @@ class CreateMentalModelRequest(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"name": obj.get("name"),
"source_query": obj.get("source_query"),
"tags": obj.get("tags"),

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