Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df013bc1e | ||
|
|
c0201cc7e0 | ||
|
|
aff4c90d8f | ||
|
|
b6a4f17cbe | ||
|
|
ffc96bec97 | ||
|
|
8cb8b9128e | ||
|
|
64d96a9c53 | ||
|
|
9dedac1dbd | ||
|
|
413ddbb45d | ||
|
|
246912f596 | ||
|
|
2d31b67d0c | ||
|
|
349c112c61 | ||
|
|
939cb40a73 | ||
|
|
c5700ff5b4 | ||
|
|
6bb83f4600 | ||
|
|
a94a90ea3f | ||
|
|
9e5a066d26 | ||
|
|
5095d5e36f | ||
|
|
22ca6a8d73 | ||
|
|
0ff36548e0 | ||
|
|
d344ef26da | ||
|
|
4fed005662 | ||
|
|
b42b35bf93 | ||
|
|
db70fdbe5e | ||
|
|
c5273f5fd4 | ||
|
|
4285e94406 | ||
|
|
35dfd3aa0c | ||
|
|
0bcbf8491b | ||
|
|
f0f0d554f2 | ||
|
|
0ad6ee3156 | ||
|
|
39bf6820d6 | ||
|
|
8ef9c48a62 | ||
|
|
7fe773c0ee | ||
|
|
58e68f3e4a | ||
|
|
4f533dde94 | ||
|
|
08d2c78ae7 | ||
|
|
8e2e2d5bf2 | ||
|
|
4a55068db7 | ||
|
|
e1f539c612 | ||
|
|
742f212b2f | ||
|
|
f2b0ff7d38 | ||
|
|
8ae3ae13a6 | ||
|
|
546d595c9f | ||
|
|
26944e25bc | ||
|
|
e6333719ee | ||
|
|
d886d3acb9 | ||
|
|
35b2cbb6ed | ||
|
|
f4390bdc2e | ||
|
|
e0f0da5d2d | ||
|
|
a9e6d9f731 | ||
|
|
8ce06e3e7c | ||
|
|
365fa3ce50 | ||
|
|
2eb1019da9 | ||
|
|
2f2db2a6e2 | ||
|
|
caa53ee370 | ||
|
|
5cdc714a38 | ||
|
|
78aa7c537e | ||
|
|
3f31cbf505 | ||
|
|
b7abf8565a | ||
|
|
682cbf38ee | ||
|
|
5e8952c54a | ||
|
|
8364b9c5d5 | ||
|
|
5a486883e8 | ||
|
|
d2c32cb8e4 | ||
|
|
ce691549ba | ||
|
|
72b61214f6 | ||
|
|
103994c25f | ||
|
|
36b5627d2c | ||
|
|
d284de28c7 | ||
|
|
93609f74ab | ||
|
|
9a5f83adb4 | ||
|
|
b4320254b2 | ||
|
|
97f7a365e8 | ||
|
|
20e17f28ad | ||
|
|
80b1badf74 | ||
|
|
ea662d062e | ||
|
|
94cf89b570 | ||
|
|
439424559e | ||
|
|
4c4b3568db | ||
|
|
a706905653 | ||
|
|
fe12be47a0 | ||
|
|
a56cd044e5 | ||
|
|
438ce98b40 | ||
|
|
446c75f3e2 | ||
|
|
276a4ba7e8 | ||
|
|
31f1c53c8f |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "hindsight",
|
||||
"description": "Official Hindsight integrations for Claude Code",
|
||||
"owner": {
|
||||
"name": "vectorize-io"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "hindsight-memory",
|
||||
"description": "Automatic long-term memory for Claude Code via Hindsight",
|
||||
"source": "./hindsight-integrations/claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Release Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'integrations/**'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # for PyPI trusted publishing
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract integration info
|
||||
id: info
|
||||
run: |
|
||||
# refs/tags/integrations/litellm/v0.1.0 → integration=litellm, version=0.1.0
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
INTEGRATION=$(echo "$TAG" | cut -d'/' -f2)
|
||||
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
|
||||
echo "integration=$INTEGRATION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Integration: $INTEGRATION, Version: $VERSION"
|
||||
|
||||
- name: Detect integration type
|
||||
id: type
|
||||
run: |
|
||||
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
|
||||
echo "type=python" >> $GITHUB_OUTPUT
|
||||
elif [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/package.json" ]; then
|
||||
echo "type=typescript" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=plugin" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
|
||||
|
||||
- name: Install uv
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build Python package
|
||||
if: steps.type.outputs.type == 'python'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Publish Python package to PyPI
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/${{ steps.info.outputs.integration }}/dist
|
||||
skip-existing: true
|
||||
|
||||
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
|
||||
|
||||
# ── Plugin integrations (claude-code) — no package to publish ───────────
|
||||
|
||||
- name: Plugin release
|
||||
if: steps.type.outputs.type == 'plugin'
|
||||
run: |
|
||||
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
|
||||
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
|
||||
|
||||
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: npm ci
|
||||
|
||||
- name: Build TypeScript package
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: npm run build
|
||||
|
||||
- name: Publish TypeScript package to npm
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
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 }}
|
||||
@@ -46,30 +46,10 @@ jobs:
|
||||
working-directory: ./hindsight-all-slim
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-litellm
|
||||
working-directory: ./hindsight-integrations/litellm
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-embed
|
||||
working-directory: ./hindsight-embed
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-crewai
|
||||
working-directory: ./hindsight-integrations/crewai
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-pydantic-ai
|
||||
working-directory: ./hindsight-integrations/pydantic-ai
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-hermes
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-agno
|
||||
working-directory: ./hindsight-integrations/agno
|
||||
run: uv build --out-dir dist
|
||||
|
||||
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
|
||||
- name: Publish hindsight-client to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -101,42 +81,12 @@ jobs:
|
||||
packages-dir: ./hindsight-all-slim/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-litellm to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/litellm/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-embed to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-embed/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-crewai to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/crewai/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-pydantic-ai to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/pydantic-ai/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-hermes to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/hermes/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-agno to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/agno/dist
|
||||
skip-existing: true
|
||||
|
||||
# Upload artifacts for GitHub release
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -148,12 +98,7 @@ jobs:
|
||||
hindsight-api/dist/*
|
||||
hindsight-all/dist/*
|
||||
hindsight-all-slim/dist/*
|
||||
hindsight-integrations/litellm/dist/*
|
||||
hindsight-embed/dist/*
|
||||
hindsight-integrations/crewai/dist/*
|
||||
hindsight-integrations/pydantic-ai/dist/*
|
||||
hindsight-integrations/hermes/dist/*
|
||||
hindsight-integrations/agno/dist/*
|
||||
retention-days: 1
|
||||
|
||||
release-typescript-client:
|
||||
@@ -205,153 +150,6 @@ jobs:
|
||||
path: hindsight-clients/typescript/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-openclaw-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: |
|
||||
set +e
|
||||
OUTPUT=$(npm publish --access public 2>&1)
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
if echo "$OUTPUT" | grep -q "cannot publish over"; then
|
||||
echo "Package version already published, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Pack for GitHub release
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-integration
|
||||
path: hindsight-integrations/openclaw/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-ai-sdk-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
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@v7
|
||||
with:
|
||||
name: ai-sdk-integration
|
||||
path: hindsight-integrations/ai-sdk/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-chat-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
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/chat
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: chat-integration
|
||||
path: hindsight-integrations/chat/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
@@ -609,7 +407,7 @@ jobs:
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -632,24 +430,6 @@ jobs:
|
||||
name: typescript-client
|
||||
path: ./artifacts/typescript-client
|
||||
|
||||
- name: Download OpenClaw Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-integration
|
||||
path: ./artifacts/openclaw-integration
|
||||
|
||||
- name: Download AI SDK Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ai-sdk-integration
|
||||
path: ./artifacts/ai-sdk-integration
|
||||
|
||||
- name: Download Chat Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: chat-integration
|
||||
path: ./artifacts/chat-integration
|
||||
|
||||
- name: Download Control Plane
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -689,19 +469,9 @@ jobs:
|
||||
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-all/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-all-slim/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/hermes/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/agno/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
|
||||
# TypeScript client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/ || true
|
||||
# OpenClaw Integration
|
||||
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
|
||||
# AI SDK Integration
|
||||
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
|
||||
# Chat Integration
|
||||
cp artifacts/chat-integration/*.tgz release-assets/ || true
|
||||
# Control Plane
|
||||
cp artifacts/control-plane/*.tgz release-assets/ || true
|
||||
# Rust CLI binaries
|
||||
|
||||
@@ -10,7 +10,103 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
core: ${{ steps.filter.outputs.core }}
|
||||
clients-ts: ${{ steps.filter.outputs.clients-ts }}
|
||||
clients-python: ${{ steps.filter.outputs.clients-python }}
|
||||
clients-rust: ${{ steps.filter.outputs.clients-rust }}
|
||||
clients-go: ${{ steps.filter.outputs.clients-go }}
|
||||
control-plane: ${{ steps.filter.outputs.control-plane }}
|
||||
cli: ${{ steps.filter.outputs.cli }}
|
||||
docker: ${{ steps.filter.outputs.docker }}
|
||||
helm: ${{ steps.filter.outputs.helm }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
hindsight-all: ${{ steps.filter.outputs.hindsight-all }}
|
||||
integration-tests: ${{ steps.filter.outputs.integration-tests }}
|
||||
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
|
||||
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
|
||||
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
|
||||
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
|
||||
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
|
||||
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
|
||||
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
|
||||
integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }}
|
||||
dev: ${{ steps.filter.outputs.dev }}
|
||||
ci: ${{ steps.filter.outputs.ci }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
core:
|
||||
- 'hindsight-api-slim/**'
|
||||
- 'hindsight-api/**'
|
||||
- 'scripts/**'
|
||||
- '.python-version'
|
||||
- '.env.example'
|
||||
clients-ts:
|
||||
- 'hindsight-clients/typescript/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
clients-python:
|
||||
- 'hindsight-clients/python/**'
|
||||
clients-rust:
|
||||
- 'hindsight-clients/rust/**'
|
||||
clients-go:
|
||||
- 'hindsight-clients/go/**'
|
||||
control-plane:
|
||||
- 'hindsight-control-plane/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
cli:
|
||||
- 'hindsight-cli/**'
|
||||
docker:
|
||||
- 'docker/**'
|
||||
helm:
|
||||
- 'helm/**'
|
||||
docs:
|
||||
- 'hindsight-docs/**'
|
||||
- '*.md'
|
||||
embed:
|
||||
- 'hindsight-embed/**'
|
||||
hindsight-all:
|
||||
- 'hindsight-all/**'
|
||||
integration-tests:
|
||||
- 'hindsight-integration-tests/**'
|
||||
integrations-openclaw:
|
||||
- 'hindsight-integrations/openclaw/**'
|
||||
integrations-ai-sdk:
|
||||
- 'hindsight-integrations/ai-sdk/**'
|
||||
integrations-chat:
|
||||
- 'hindsight-integrations/chat/**'
|
||||
integrations-claude-code:
|
||||
- 'hindsight-integrations/claude-code/**'
|
||||
integrations-crewai:
|
||||
- 'hindsight-integrations/crewai/**'
|
||||
integrations-litellm:
|
||||
- 'hindsight-integrations/litellm/**'
|
||||
integrations-pydantic-ai:
|
||||
- 'hindsight-integrations/pydantic-ai/**'
|
||||
integrations-hermes:
|
||||
- 'hindsight-integrations/hermes/**'
|
||||
dev:
|
||||
- 'hindsight-dev/**'
|
||||
ci:
|
||||
- '.github/**'
|
||||
|
||||
build-api-python-versions:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -34,6 +130,11 @@ jobs:
|
||||
run: uv build
|
||||
|
||||
build-typescript-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -53,6 +154,11 @@ jobs:
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
build-openclaw-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -75,7 +181,35 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
test-claude-code-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-claude-code == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install pytest
|
||||
run: pip install pytest
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/claude-code
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
build-ai-sdk-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -99,6 +233,11 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
test-ai-sdk-integration-deno:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -123,6 +262,11 @@ jobs:
|
||||
run: npm run test:deno
|
||||
|
||||
build-chat-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-chat == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -146,6 +290,12 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
build-control-plane:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.control-plane == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -198,6 +348,11 @@ jobs:
|
||||
fi
|
||||
|
||||
build-docs:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.docs == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -217,6 +372,12 @@ jobs:
|
||||
run: npm run build --workspace=hindsight-docs
|
||||
|
||||
test-rust-cli:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.cli == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -337,6 +498,11 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
lint-helm-chart:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.helm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -351,6 +517,13 @@ jobs:
|
||||
run: helm lint helm/hindsight
|
||||
|
||||
build-docker-images:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.docker == 'true' ||
|
||||
needs.detect-changes.outputs.control-plane == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
name: Build Docker (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -434,6 +607,11 @@ jobs:
|
||||
run: ./docker/test-image.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
|
||||
|
||||
test-api:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -443,6 +621,9 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
|
||||
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION_NAME: ${{ secrets.AWS_REGION_NAME }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
|
||||
|
||||
@@ -499,6 +680,12 @@ jobs:
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-python-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-python == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -601,6 +788,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-typescript-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -708,6 +901,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-typescript-client-deno:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -821,6 +1020,11 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
build-rust-cli-arm64:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.cli == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
@@ -845,6 +1049,12 @@ jobs:
|
||||
run: cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
test-rust-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-rust == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -951,6 +1161,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-go-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-go == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1055,6 +1271,13 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-openclaw-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.embed == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1162,6 +1385,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.integration-tests == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1259,6 +1488,11 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-crewai-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-crewai == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -1288,6 +1522,11 @@ jobs:
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-litellm-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-litellm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -1317,6 +1556,11 @@ jobs:
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-pydantic-ai-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-pydantic-ai == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -1345,7 +1589,46 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/pydantic-ai
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-hermes-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-hermes == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build hermes integration
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-pip-slim:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1406,6 +1689,12 @@ jobs:
|
||||
cat /tmp/slim-api-server.log 2>/dev/null || true
|
||||
|
||||
test-embed:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.embed == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1459,6 +1748,12 @@ jobs:
|
||||
run: ./test.sh
|
||||
|
||||
test-hindsight-all:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.hindsight-all == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1512,6 +1807,16 @@ jobs:
|
||||
run: uv run pytest tests/ -v
|
||||
|
||||
test-doc-examples:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.clients-python == 'true' ||
|
||||
needs.detect-changes.outputs.clients-go == 'true' ||
|
||||
needs.detect-changes.outputs.cli == 'true' ||
|
||||
needs.detect-changes.outputs.docs == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1651,6 +1956,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-upgrade:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.dev == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1775,6 +2086,9 @@ jobs:
|
||||
- name: Run generate-clients
|
||||
run: ./scripts/generate-clients.sh
|
||||
|
||||
- name: Run generate-docs-skill
|
||||
run: ./scripts/generate-docs-skill.sh
|
||||
|
||||
- name: Run lint
|
||||
run: ./scripts/hooks/lint.sh
|
||||
|
||||
@@ -1789,6 +2103,7 @@ jobs:
|
||||
echo "Please run the following commands locally and commit the changes:"
|
||||
echo " ./scripts/generate-openapi.sh"
|
||||
echo " ./scripts/generate-clients.sh"
|
||||
echo " ./scripts/generate-docs-skill.sh"
|
||||
echo " ./scripts/hooks/lint.sh"
|
||||
echo ""
|
||||
git diff --stat
|
||||
@@ -1797,6 +2112,11 @@ jobs:
|
||||
echo "✓ All generated files are up to date"
|
||||
|
||||
check-openapi-compatibility:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
|
||||
[](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://gitcgr.com/vectorize-io/hindsight)
|
||||

|
||||

|
||||
<br/>
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.19
|
||||
appVersion: "0.4.19"
|
||||
version: 0.4.20
|
||||
appVersion: "0.4.20"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all-slim"
|
||||
version = "0.4.19"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -34,7 +34,6 @@ Using context manager:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
@@ -140,13 +139,17 @@ class HindsightEmbedded:
|
||||
return
|
||||
|
||||
if self._closed:
|
||||
raise RuntimeError("Cannot use HindsightEmbedded after it has been closed")
|
||||
raise RuntimeError(
|
||||
"Cannot use HindsightEmbedded after it has been closed"
|
||||
)
|
||||
|
||||
# Use embed manager interface for daemon management
|
||||
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
|
||||
success = self._manager.ensure_running(self.config, self.profile)
|
||||
if not success:
|
||||
raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'")
|
||||
raise RuntimeError(
|
||||
f"Failed to start daemon for profile '{self.profile}'"
|
||||
)
|
||||
|
||||
# Get daemon URL and create client
|
||||
daemon_url = self._manager.get_url(self.profile)
|
||||
@@ -375,3 +378,45 @@ class HindsightEmbedded:
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the client is initialized."""
|
||||
return self._started and not self._closed and self._client is not None
|
||||
|
||||
def start_ui(self, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
|
||||
"""Start the control plane web UI.
|
||||
|
||||
The daemon is started automatically if not already running.
|
||||
|
||||
Args:
|
||||
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
||||
hostname: Hostname to bind to. Defaults to 0.0.0.0.
|
||||
|
||||
Returns:
|
||||
True if UI started successfully.
|
||||
"""
|
||||
self._ensure_started()
|
||||
return self._manager.start_ui(self.profile, ui_port, hostname)
|
||||
|
||||
def stop_ui(self, ui_port: int | None = None) -> bool:
|
||||
"""Stop the control plane web UI.
|
||||
|
||||
Args:
|
||||
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
|
||||
|
||||
Returns:
|
||||
True if stopped successfully.
|
||||
"""
|
||||
return self._manager.stop_ui(self.profile, ui_port)
|
||||
|
||||
def is_ui_running(self, ui_port: int | None = None) -> bool:
|
||||
"""Check if the control plane web UI is running.
|
||||
|
||||
Args:
|
||||
ui_port: Port to check. Defaults to daemon_port + 10000.
|
||||
|
||||
Returns:
|
||||
True if UI is running and responsive.
|
||||
"""
|
||||
return self._manager.is_ui_running(self.profile, ui_port)
|
||||
|
||||
@property
|
||||
def ui_url(self) -> str:
|
||||
"""Get the UI URL for this profile."""
|
||||
return self._manager.get_ui_url(self.profile)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.4.19"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.19"
|
||||
__version__ = "0.4.20"
|
||||
|
||||
+15
-2
@@ -11,6 +11,7 @@ block; see migrations.py for how this is handled safely.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "c1a2b3d4e5f6"
|
||||
@@ -25,9 +26,21 @@ def _get_schema_prefix() -> str:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
|
||||
# pg_trgm ships with most PostgreSQL installations as a contrib module.
|
||||
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
# On managed services (e.g. Azure Flexible Server), the extension may not be
|
||||
# available or may require manual enablement. We gracefully skip the index
|
||||
# creation if the extension cannot be loaded — the entity resolver will
|
||||
# auto-detect and fall back to the "full" lookup strategy at runtime. See #626.
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
except Exception:
|
||||
# Extension not available (managed Postgres, insufficient privileges, etc.)
|
||||
# Roll back the failed statement and skip index creation.
|
||||
conn.execute(sa.text("ROLLBACK"))
|
||||
conn.execute(sa.text("BEGIN"))
|
||||
return
|
||||
|
||||
schema = _get_schema_prefix()
|
||||
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
|
||||
|
||||
@@ -72,6 +72,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
|
||||
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
|
||||
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
|
||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||
@@ -169,6 +170,15 @@ class RecallRequest(BaseModel):
|
||||
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
|
||||
)
|
||||
|
||||
@field_validator("query")
|
||||
@classmethod
|
||||
def validate_query_not_empty(cls, v: str) -> str:
|
||||
from ..engine.search.retrieval import tokenize_query
|
||||
|
||||
if not tokenize_query(v):
|
||||
raise ValueError("query must contain at least one word character after normalization")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tags_exclusive(self) -> "RecallRequest":
|
||||
if self.tags is not None and self.tag_groups is not None:
|
||||
@@ -415,6 +425,27 @@ class MemoryItem(BaseModel):
|
||||
default=None,
|
||||
description="Optional tags for visibility scoping. Memories with tags can be filtered during recall.",
|
||||
)
|
||||
|
||||
@field_validator("tags", mode="before")
|
||||
@classmethod
|
||||
def coerce_tags(cls, v):
|
||||
"""Coerce JSON-string tags to list.
|
||||
|
||||
MCP tool bridges sometimes serialize JSON arrays as strings during
|
||||
transport, e.g. '["a", "b"]' instead of ["a", "b"]. This validator
|
||||
parses such strings back into lists so the retain call succeeds.
|
||||
A plain non-JSON string is wrapped in a single-element list.
|
||||
"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return [v]
|
||||
return v
|
||||
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field(
|
||||
default=None,
|
||||
title="ObservationScopes",
|
||||
@@ -669,6 +700,25 @@ class ReflectRequest(BaseModel):
|
||||
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
|
||||
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
|
||||
)
|
||||
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
|
||||
default=None,
|
||||
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
|
||||
)
|
||||
exclude_mental_models: bool = Field(
|
||||
default=False,
|
||||
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
|
||||
)
|
||||
exclude_mental_model_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Exclude specific mental models by ID from the reflect loop.",
|
||||
)
|
||||
|
||||
@field_validator("fact_types")
|
||||
@classmethod
|
||||
def validate_reflect_fact_types(cls, v: list[str] | None) -> list[str] | None:
|
||||
if v is not None and len(v) == 0:
|
||||
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tags_exclusive(self) -> "ReflectRequest":
|
||||
@@ -1435,6 +1485,25 @@ class MentalModelTrigger(BaseModel):
|
||||
default=False,
|
||||
description="If true, refresh this mental model after observations consolidation (real-time mode)",
|
||||
)
|
||||
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
|
||||
default=None,
|
||||
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
|
||||
)
|
||||
exclude_mental_models: bool = Field(
|
||||
default=False,
|
||||
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
|
||||
)
|
||||
exclude_mental_model_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Exclude specific mental models by ID from the reflect loop.",
|
||||
)
|
||||
|
||||
@field_validator("fact_types")
|
||||
@classmethod
|
||||
def validate_fact_types(cls, v: list[str] | None) -> list[str] | None:
|
||||
if v is not None and len(v) == 0:
|
||||
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
|
||||
return v
|
||||
|
||||
|
||||
class MentalModelResponse(BaseModel):
|
||||
@@ -1995,6 +2064,26 @@ def create_app(
|
||||
# This is required for mounted sub-applications where lifespan may not fire
|
||||
app.state.memory = memory
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch OpenAPI schema: align ValidationError with Pydantic v2 error format
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI auto-generates ValidationError with only loc/msg/type, but Pydantic
|
||||
# v2 actually returns additional fields: input (the rejected value), ctx (extra
|
||||
# context dict), and url (link to error docs). Without these in the spec,
|
||||
# generated clients using strict JSON decoding break on real 422 responses.
|
||||
_original_openapi = app.openapi
|
||||
|
||||
def _patched_openapi() -> dict[str, Any]:
|
||||
schema = _original_openapi()
|
||||
ve = schema.get("components", {}).get("schemas", {}).get("ValidationError")
|
||||
if ve and "input" not in ve.get("properties", {}):
|
||||
ve["properties"]["input"] = {"title": "Input"}
|
||||
ve["properties"]["ctx"] = {"title": "Context", "type": "object"}
|
||||
ve["properties"]["url"] = {"title": "URL", "type": "string"}
|
||||
return schema
|
||||
|
||||
app.openapi = _patched_openapi # type: ignore[assignment]
|
||||
|
||||
# Add HTTP metrics middleware
|
||||
@app.middleware("http")
|
||||
async def http_metrics_middleware(request, call_next):
|
||||
@@ -2505,6 +2594,9 @@ def _register_routes(app: FastAPI):
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
tag_groups=request.tag_groups,
|
||||
fact_types=request.fact_types,
|
||||
exclude_mental_models=request.exclude_mental_models,
|
||||
exclude_mental_model_ids=request.exclude_mental_model_ids,
|
||||
)
|
||||
|
||||
# Build based_on (memories + mental_models + directives) if facts are requested
|
||||
@@ -2580,6 +2672,14 @@ def _register_routes(app: FastAPI):
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except LLMNotAvailableError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except TimeoutError as e:
|
||||
logger.error("Timeout in /v1/default/banks/%s/reflect: %s", bank_id, e)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=str(e) or "Reflect operation timed out. Consider reducing the budget or simplifying the query.",
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -2940,6 +3040,8 @@ def _register_routes(app: FastAPI):
|
||||
request_context=request_context,
|
||||
)
|
||||
return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued")
|
||||
except LLMNotAvailableError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
|
||||
@@ -83,6 +83,9 @@ _current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default
|
||||
_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)
|
||||
|
||||
# Context variable for MCP pre-authentication flag (set when MCP_AUTH_TOKEN validates)
|
||||
_current_mcp_authenticated: ContextVar[bool] = ContextVar("current_mcp_authenticated", default=False)
|
||||
|
||||
|
||||
def get_current_bank_id() -> str | None:
|
||||
"""Get the current bank_id from context."""
|
||||
@@ -104,6 +107,11 @@ def get_current_api_key_id() -> str | None:
|
||||
return _current_api_key_id.get()
|
||||
|
||||
|
||||
def get_current_mcp_authenticated() -> bool:
|
||||
"""Get whether the request was pre-authenticated by MCP transport auth."""
|
||||
return _current_mcp_authenticated.get()
|
||||
|
||||
|
||||
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
"""
|
||||
Create and configure the Hindsight MCP server.
|
||||
@@ -164,6 +172,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
|
||||
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
|
||||
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
|
||||
include_bank_id_param=multi_bank,
|
||||
tools=base_tools,
|
||||
)
|
||||
@@ -312,6 +321,7 @@ class MCPMiddleware:
|
||||
tenant_context = None
|
||||
auth_tenant_id: str | None = None
|
||||
auth_api_key_id: str | None = None
|
||||
mcp_pre_authenticated = False
|
||||
if MCP_AUTH_TOKEN:
|
||||
# Legacy authentication mode - validate against static token
|
||||
if not auth_token:
|
||||
@@ -320,8 +330,9 @@ class MCPMiddleware:
|
||||
if auth_token != MCP_AUTH_TOKEN:
|
||||
await self._send_error(send, 401, "Invalid authentication token")
|
||||
return
|
||||
# Legacy mode doesn't use tenant schemas
|
||||
# Legacy mode: mark as pre-authenticated so tenant extension won't re-validate
|
||||
tenant_context = None
|
||||
mcp_pre_authenticated = True
|
||||
else:
|
||||
# Use TenantExtension.authenticate_mcp() for auth
|
||||
try:
|
||||
@@ -368,13 +379,15 @@ class MCPMiddleware:
|
||||
# - 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
|
||||
# Set bank_id, api_key, tenant_id, api_key_id, and mcp_authenticated 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
|
||||
# Store MCP pre-authentication flag to skip tenant re-validation
|
||||
mcp_auth_token = _current_mcp_authenticated.set(mcp_pre_authenticated)
|
||||
try:
|
||||
new_scope = scope.copy()
|
||||
new_scope["path"] = new_path
|
||||
@@ -419,6 +432,7 @@ class MCPMiddleware:
|
||||
_current_tenant_id.reset(tenant_id_token)
|
||||
if api_key_id_token is not None:
|
||||
_current_api_key_id.reset(api_key_id_token)
|
||||
_current_mcp_authenticated.reset(mcp_auth_token)
|
||||
if schema_token is not None:
|
||||
_current_schema.reset(schema_token)
|
||||
|
||||
|
||||
@@ -338,7 +338,9 @@ ENV_WORKER_CONSOLIDATION_MAX_SLOTS = "HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLO
|
||||
# Reflect agent settings
|
||||
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
|
||||
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
|
||||
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
|
||||
ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
|
||||
|
||||
# Disposition settings
|
||||
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
|
||||
@@ -353,7 +355,7 @@ DEFAULT_LLM_PROVIDER = "openai"
|
||||
# Provider-specific default models
|
||||
PROVIDER_DEFAULT_MODELS = {
|
||||
"openai": "gpt-4o-mini",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"anthropic": "claude-haiku-4-5",
|
||||
"gemini": "gemini-2.5-flash",
|
||||
"groq": "openai/gpt-oss-120b",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
@@ -363,6 +365,9 @@ PROVIDER_DEFAULT_MODELS = {
|
||||
"openai-codex": "gpt-5.2-codex",
|
||||
"claude-code": "claude-sonnet-4-5-20250929",
|
||||
"mock": "mock-model",
|
||||
"none": "none",
|
||||
"litellm": "gpt-4o-mini",
|
||||
"bedrock": "us.amazon.nova-2-lite-v1:0",
|
||||
}
|
||||
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
|
||||
DEFAULT_LLM_MAX_CONCURRENT = 32
|
||||
@@ -499,6 +504,8 @@ 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
|
||||
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
|
||||
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
|
||||
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS = -1 # Token budget for source facts in search_observations (-1 = disabled)
|
||||
|
||||
# Disposition defaults (None = not set, fall back to bank DB value or 3)
|
||||
DEFAULT_DISPOSITION_SKEPTICISM = None
|
||||
@@ -770,6 +777,7 @@ class HindsightConfig:
|
||||
|
||||
# Reflect agent settings
|
||||
reflect_mission: str | None
|
||||
reflect_source_facts_max_tokens: int
|
||||
|
||||
# Disposition settings (hierarchical - can be overridden per bank; None = fall back to DB)
|
||||
disposition_skepticism: int | None
|
||||
@@ -801,6 +809,7 @@ class HindsightConfig:
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
reflect_max_context_tokens: int
|
||||
reflect_wall_timeout: int
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
otel_traces_enabled: bool
|
||||
@@ -867,6 +876,7 @@ class HindsightConfig:
|
||||
"observations_mission",
|
||||
# Reflect settings
|
||||
"reflect_mission",
|
||||
"reflect_source_facts_max_tokens",
|
||||
# Disposition settings
|
||||
"disposition_skepticism",
|
||||
"disposition_literalism",
|
||||
@@ -949,9 +959,19 @@ class HindsightConfig:
|
||||
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
|
||||
)
|
||||
|
||||
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
|
||||
if self.llm_provider == "none":
|
||||
self.retain_extraction_mode = "chunks"
|
||||
self.enable_observations = False
|
||||
logger.info(
|
||||
"LLM provider set to 'none': forcing retain_extraction_mode='chunks', "
|
||||
"disabling observations/consolidation. Reflect will return HTTP 400."
|
||||
)
|
||||
|
||||
# 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:
|
||||
# (not applicable when provider is "none" since no LLM calls are made)
|
||||
if self.llm_provider != "none" and 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 "
|
||||
@@ -1274,7 +1294,11 @@ class HindsightConfig:
|
||||
reflect_max_context_tokens=int(
|
||||
os.getenv(ENV_REFLECT_MAX_CONTEXT_TOKENS, str(DEFAULT_REFLECT_MAX_CONTEXT_TOKENS))
|
||||
),
|
||||
reflect_wall_timeout=int(os.getenv(ENV_REFLECT_WALL_TIMEOUT, str(DEFAULT_REFLECT_WALL_TIMEOUT))),
|
||||
reflect_mission=os.getenv(ENV_REFLECT_MISSION) or None,
|
||||
reflect_source_facts_max_tokens=int(
|
||||
os.getenv(ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS))
|
||||
),
|
||||
# Disposition settings (None = fall back to DB value)
|
||||
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
|
||||
if os.getenv(ENV_DISPOSITION_SKEPTICISM)
|
||||
|
||||
@@ -75,6 +75,7 @@ class EntityResolver:
|
||||
"""
|
||||
self.pool = pool
|
||||
self.entity_lookup = entity_lookup
|
||||
self._pg_trgm_checked = False
|
||||
# Keyed by asyncio task id so concurrent retain batches never mix their
|
||||
# pending updates. flush_pending_stats() pops only the calling task's items.
|
||||
self._pending_stats: dict[int, list[_EntityStat]] = {}
|
||||
@@ -85,6 +86,19 @@ class EntityResolver:
|
||||
task = asyncio.current_task()
|
||||
return id(task) if task is not None else 0
|
||||
|
||||
def discard_pending_stats(self) -> None:
|
||||
"""
|
||||
Discard accumulated entity stats and co-occurrence counts for the current task.
|
||||
|
||||
Call this on any exception path between resolve_entities_batch /
|
||||
link_units_to_entities_batch and flush_pending_stats() to prevent the
|
||||
per-task dicts from growing unbounded when tasks fail before flushing.
|
||||
Safe to call even if no entries exist for the current task.
|
||||
"""
|
||||
key = self._task_key()
|
||||
self._pending_stats.pop(key, None)
|
||||
self._pending_cooccurrences.pop(key, None)
|
||||
|
||||
async def flush_pending_stats(self) -> None:
|
||||
"""
|
||||
Flush accumulated entity stats and co-occurrence counts for the current task.
|
||||
@@ -202,6 +216,20 @@ class EntityResolver:
|
||||
taxonomy_lookup: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
if self.entity_lookup == "trigram":
|
||||
# Auto-detect pg_trgm availability on first call and fall back to
|
||||
# "full" strategy if the extension is not installed. See #626.
|
||||
if not self._pg_trgm_checked:
|
||||
self._pg_trgm_checked = True
|
||||
has_trgm = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")
|
||||
if not has_trgm:
|
||||
logger.warning(
|
||||
"pg_trgm extension is not available — falling back to 'full' "
|
||||
"entity lookup strategy. Install pg_trgm for faster entity "
|
||||
"resolution on large banks. See: "
|
||||
"https://github.com/vectorize-io/hindsight/issues/626"
|
||||
)
|
||||
self.entity_lookup = "full"
|
||||
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
|
||||
return await self._resolve_entities_batch_trigram(conn, bank_id, entities_data, unit_event_date)
|
||||
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
|
||||
|
||||
@@ -477,19 +505,42 @@ class EntityResolver:
|
||||
id_by_name: dict[str, str] = {row["name_lower"]: row["id"] for row in inserted_rows}
|
||||
|
||||
# Fallback SELECT for names that conflicted (another worker won the race).
|
||||
missing = [n for n, _ in sorted_groups if n not in id_by_name]
|
||||
if missing:
|
||||
#
|
||||
# IMPORTANT: we must let PostgreSQL do the lowercasing on BOTH sides of the
|
||||
# comparison. Python's str.lower() and PostgreSQL's LOWER() differ for some
|
||||
# Unicode characters — most notably Turkish İ (U+0130):
|
||||
# Python: 'İstanbul'.lower() == 'i\u0307stanbul' (i + combining dot, 2 chars)
|
||||
# PostgreSQL: LOWER('İstanbul') == 'istanbul' (plain i, 1 char)
|
||||
# Passing a Python-lowercased name to "LOWER(canonical_name) = ANY($2::text[])"
|
||||
# would fail to match the stored entity, leaving entity_id as None and causing
|
||||
# a NOT NULL constraint violation on unit_entities.entity_id.
|
||||
#
|
||||
# Fix: pass the original (mixed-case) input names and use
|
||||
# "LOWER(canonical_name) = ANY(SELECT LOWER(n) FROM unnest($2) AS n)" so
|
||||
# PostgreSQL lowercases both sides identically. The query also returns the
|
||||
# original input_name so we can index id_by_name by Python's lower() of that
|
||||
# name, which is what the assignment loop below uses as its lookup key.
|
||||
missing_original = [g.name for name_lower, g in sorted_groups if name_lower not in id_by_name]
|
||||
if missing_original:
|
||||
existing_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, LOWER(canonical_name) AS name_lower
|
||||
FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
|
||||
SELECT e.id, LOWER(e.canonical_name) AS name_lower, inputs.input_name
|
||||
FROM {fq_table("entities")} e
|
||||
JOIN (
|
||||
SELECT LOWER(n) AS input_name_lower, n AS input_name
|
||||
FROM unnest($2::text[]) AS n
|
||||
) AS inputs ON LOWER(e.canonical_name) = inputs.input_name_lower
|
||||
WHERE e.bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
missing,
|
||||
missing_original,
|
||||
)
|
||||
for row in existing_rows:
|
||||
id_by_name[row["name_lower"]] = row["id"]
|
||||
# Also index by Python's lower() of the original input name so the
|
||||
# assignment loop (which uses Python-lowercased keys) finds it even
|
||||
# when Python and PostgreSQL produce different lowercase strings.
|
||||
id_by_name[row["input_name"].lower()] = row["id"]
|
||||
|
||||
# Assign entity IDs back and queue one stat per original mention so that
|
||||
# flush_pending_stats() increments mention_count by the true mention count,
|
||||
|
||||
@@ -125,7 +125,10 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
|
||||
"openai-codex",
|
||||
"claude-code",
|
||||
"mock",
|
||||
"none",
|
||||
"vertexai",
|
||||
"litellm",
|
||||
"bedrock",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -172,7 +175,9 @@ def create_llm_provider(
|
||||
ClaudeCodeLLM,
|
||||
CodexLLM,
|
||||
GeminiLLM,
|
||||
LiteLLMLLM,
|
||||
MockLLM,
|
||||
NoneLLM,
|
||||
OpenAICompatibleLLM,
|
||||
)
|
||||
|
||||
@@ -205,6 +210,15 @@ def create_llm_provider(
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "none":
|
||||
return NoneLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower in ("gemini", "vertexai"):
|
||||
return GeminiLLM(
|
||||
provider=provider,
|
||||
@@ -227,6 +241,26 @@ def create_llm_provider(
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "litellm":
|
||||
return LiteLLMLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "bedrock":
|
||||
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
|
||||
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
|
||||
return LiteLLMLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=bedrock_model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax"):
|
||||
return OpenAICompatibleLLM(
|
||||
provider=provider,
|
||||
@@ -296,7 +330,10 @@ class LLMProvider:
|
||||
"openai-codex",
|
||||
"claude-code",
|
||||
"mock",
|
||||
"none",
|
||||
"minimax",
|
||||
"litellm",
|
||||
"bedrock",
|
||||
]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
||||
@@ -675,10 +712,13 @@ class LLMProvider:
|
||||
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY", "")
|
||||
|
||||
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
|
||||
# ollama (local), or vertexai (uses GCP service account credentials)
|
||||
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
|
||||
# ollama (local), vertexai (uses GCP service account credentials),
|
||||
# or litellm (uses provider-specific auth, e.g. AWS credentials for Bedrock)
|
||||
if not api_key and not requires_api_key(provider):
|
||||
pass # Provider handles its own auth
|
||||
elif not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex or claude-code)"
|
||||
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex, claude-code, or litellm)"
|
||||
)
|
||||
|
||||
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
|
||||
@@ -692,12 +732,13 @@ class LLMProvider:
|
||||
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
|
||||
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
|
||||
|
||||
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
|
||||
# ollama (local), or vertexai (uses GCP service account credentials)
|
||||
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
|
||||
# API key not needed for providers with their own auth mechanisms
|
||||
if not api_key and not requires_api_key(provider):
|
||||
pass # Provider handles its own auth
|
||||
elif not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required "
|
||||
"(unless using openai-codex or claude-code)"
|
||||
"(unless using openai-codex, claude-code, or litellm)"
|
||||
)
|
||||
|
||||
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
|
||||
@@ -711,12 +752,13 @@ class LLMProvider:
|
||||
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
|
||||
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
|
||||
|
||||
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
|
||||
# ollama (local), or vertexai (uses GCP service account credentials)
|
||||
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
|
||||
# API key not needed for providers with their own auth mechanisms
|
||||
if not api_key and not requires_api_key(provider):
|
||||
pass # Provider handles its own auth
|
||||
elif not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required "
|
||||
"(unless using openai-codex or claude-code)"
|
||||
"(unless using openai-codex, claude-code, or litellm)"
|
||||
)
|
||||
|
||||
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
|
||||
|
||||
@@ -23,7 +23,7 @@ import asyncpg
|
||||
import httpx
|
||||
import tiktoken
|
||||
|
||||
from ..config import get_config
|
||||
from ..config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS, get_config
|
||||
from ..metrics import get_metrics_collector
|
||||
from ..tracing import create_operation_span
|
||||
from ..utils import mask_network_location
|
||||
@@ -67,6 +67,13 @@ def fq_table(table_name: str) -> str:
|
||||
return f"{get_current_schema()}.{table_name}"
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> str:
|
||||
"""JSON serializer for types commonly carried through async task payloads."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
# Tables that must be schema-qualified (for runtime validation)
|
||||
_PROTECTED_TABLES = frozenset(
|
||||
[
|
||||
@@ -321,6 +328,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Apply defaults from config
|
||||
db_url = db_url or config.database_url
|
||||
memory_llm_provider = memory_llm_provider or config.llm_provider
|
||||
|
||||
# Force skip LLM verification when provider is "none" (no LLM to verify)
|
||||
if memory_llm_provider == "none":
|
||||
self._skip_llm_verification = True
|
||||
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
|
||||
if not memory_llm_api_key and requires_api_key(memory_llm_provider):
|
||||
raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
|
||||
@@ -492,24 +503,28 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"""The configured tenant extension, if any."""
|
||||
return self._tenant_extension
|
||||
|
||||
async def _validate_operation(self, validation_coro) -> None:
|
||||
async def _validate_operation(self, validation_coro) -> "ValidationResult | None":
|
||||
"""
|
||||
Run validation if an operation validator is configured.
|
||||
|
||||
Args:
|
||||
validation_coro: Coroutine that returns a ValidationResult
|
||||
|
||||
Returns:
|
||||
The ValidationResult (may contain enrichment fields), or None if no validator.
|
||||
|
||||
Raises:
|
||||
OperationValidationError: If validation fails
|
||||
"""
|
||||
if self._operation_validator is None:
|
||||
return
|
||||
return None
|
||||
|
||||
from hindsight_api.extensions import OperationValidationError
|
||||
from hindsight_api.extensions import OperationValidationError, ValidationResult
|
||||
|
||||
result = await validation_coro
|
||||
if not result.allowed:
|
||||
raise OperationValidationError(result.reason or "Operation not allowed", result.status_code)
|
||||
return result
|
||||
|
||||
async def _authenticate_tenant(self, request_context: "RequestContext | None") -> str:
|
||||
"""
|
||||
@@ -538,6 +553,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if request_context.internal:
|
||||
return _current_schema.get()
|
||||
|
||||
# For MCP requests already authenticated via MCP_AUTH_TOKEN, skip tenant re-validation.
|
||||
# The MCP transport layer already verified the token; re-validating against the tenant
|
||||
# extension would fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
|
||||
if request_context.mcp_authenticated:
|
||||
return _current_schema.get()
|
||||
|
||||
# Authenticate through tenant extension (always set, may be default no-auth extension)
|
||||
tenant_context = await self._tenant_extension.authenticate(request_context)
|
||||
|
||||
@@ -803,6 +824,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if not bank_id:
|
||||
raise ValueError("bank_id is required for consolidation task")
|
||||
|
||||
# Skip consolidation when LLM provider is "none"
|
||||
if self._llm_config.provider == "none":
|
||||
logger.info(f"[CONSOLIDATION] Skipping consolidation for bank {bank_id}: LLM provider is 'none'")
|
||||
return {"memories_processed": 0, "skipped": True}
|
||||
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
from .consolidation import run_consolidation_job
|
||||
@@ -868,14 +894,23 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags = mental_model.get("tags")
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
# Read reflect options from trigger (if stored)
|
||||
trigger_data = mental_model.get("trigger") or {}
|
||||
fact_types = trigger_data.get("fact_types")
|
||||
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
|
||||
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
|
||||
|
||||
# Run reflect to generate new content, excluding the mental model being refreshed
|
||||
# Always add self to excluded IDs to prevent circular reference
|
||||
reflect_result = await self.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query=source_query,
|
||||
request_context=internal_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
exclude_mental_model_ids=[mental_model_id],
|
||||
fact_types=fact_types,
|
||||
exclude_mental_models=exclude_mental_models,
|
||||
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
|
||||
)
|
||||
|
||||
generated_content = reflect_result.text or "No content generated"
|
||||
@@ -2030,7 +2065,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
fact_type_override=fact_type_override,
|
||||
confidence_score=confidence_score,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
if result and result.contents is not None:
|
||||
contents = result.contents
|
||||
|
||||
# Apply batch-level document_id to contents that don't have their own (backwards compatibility)
|
||||
if document_id:
|
||||
@@ -2226,6 +2263,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Resolve bank-specific config for this operation
|
||||
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
|
||||
# Force chunks mode when LLM provider is "none" (no LLM available for fact extraction)
|
||||
if self._llm_config.provider == "none":
|
||||
resolved_config.retain_extraction_mode = "chunks"
|
||||
resolved_config.enable_observations = False
|
||||
|
||||
# Apply strategy overrides: explicit strategy > bank default strategy
|
||||
from hindsight_api.config_resolver import apply_strategy
|
||||
|
||||
@@ -2397,8 +2439,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
max_entity_tokens=max_entity_tokens,
|
||||
include_chunks=include_chunks,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_recall(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_recall(ctx))
|
||||
if result:
|
||||
if result.tags is not None:
|
||||
tags = result.tags
|
||||
if result.tags_match is not None:
|
||||
tags_match = result.tags_match
|
||||
if result.tag_groups is not None:
|
||||
tag_groups = result.tag_groups
|
||||
|
||||
# Map budget enum to thinking_budget number (default to MID if None)
|
||||
budget_mapping = {Budget.LOW: 100, Budget.MID: 300, Budget.HIGH: 1000}
|
||||
@@ -3196,7 +3248,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
source_rows = await sf_conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, fact_type, context, occurred_start, occurred_end,
|
||||
mentioned_at, document_id, chunk_id, tags
|
||||
mentioned_at, document_id, chunk_id, tags, metadata
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
""",
|
||||
@@ -3217,6 +3269,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
occurred_end=r["occurred_end"].isoformat() if r["occurred_end"] else None,
|
||||
mentioned_at=r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
|
||||
document_id=r["document_id"],
|
||||
metadata=r["metadata"],
|
||||
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
|
||||
tags=r["tags"] or None,
|
||||
)
|
||||
@@ -3295,6 +3348,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
occurred_end=result_dict.get("occurred_end"),
|
||||
mentioned_at=result_dict.get("mentioned_at"),
|
||||
document_id=result_dict.get("document_id"),
|
||||
metadata=result_dict.get("metadata"),
|
||||
chunk_id=result_dict.get("chunk_id"),
|
||||
tags=result_dict.get("tags"),
|
||||
source_fact_ids=source_fact_ids_by_obs.get(result_id) if include_source_facts else None,
|
||||
@@ -5113,6 +5167,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
exclude_mental_model_ids: list[str] | None = None,
|
||||
fact_types: list[str] | None = None,
|
||||
exclude_mental_models: bool = False,
|
||||
_skip_span: bool = False,
|
||||
) -> ReflectResult:
|
||||
"""
|
||||
@@ -5150,6 +5206,15 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if self._reflect_llm_config is None:
|
||||
raise ValueError("Memory LLM API key not set. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
|
||||
|
||||
# Block reflect when LLM provider is "none"
|
||||
if self._llm_config.provider == "none":
|
||||
from .providers.none_llm import LLMNotAvailableError
|
||||
|
||||
raise LLMNotAvailableError(
|
||||
"Reflect requires an LLM provider. Current provider is set to 'none'. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
# Authenticate tenant and set schema in context (for fq_table())
|
||||
await self._authenticate_tenant(request_context)
|
||||
|
||||
@@ -5188,6 +5253,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
effective_budget = budget or Budget.LOW
|
||||
max_iterations = max(1, int(base_max_iterations * budget_multipliers.get(effective_budget, 1.0)))
|
||||
max_context_tokens = config.reflect_max_context_tokens
|
||||
wall_timeout = config.reflect_wall_timeout
|
||||
|
||||
# Run agentic loop - acquire connections only when needed for DB operations
|
||||
# (not held during LLM calls which can be slow)
|
||||
@@ -5219,6 +5285,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
pending_consolidation=pending_consolidation,
|
||||
)
|
||||
|
||||
# Get reflect source facts config (hierarchical: env → tenant → bank)
|
||||
config_dict = await self._config_resolver.get_bank_config(bank_id, request_context)
|
||||
reflect_source_facts_max_tokens = config_dict.get(
|
||||
"reflect_source_facts_max_tokens", DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
|
||||
)
|
||||
|
||||
async def search_observations_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
|
||||
return await tool_search_observations(
|
||||
self,
|
||||
@@ -5231,8 +5303,14 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tag_groups=tag_groups,
|
||||
last_consolidated_at=last_consolidated_at,
|
||||
pending_consolidation=pending_consolidation,
|
||||
source_facts_max_tokens=reflect_source_facts_max_tokens,
|
||||
)
|
||||
|
||||
# Determine which tools to enable based on fact_types and exclude_mental_models
|
||||
include_observations = fact_types is None or "observation" in fact_types
|
||||
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
|
||||
include_recall = bool(recall_fact_types)
|
||||
|
||||
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
|
||||
return await tool_recall(
|
||||
self,
|
||||
@@ -5244,6 +5322,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
fact_types=recall_fact_types if fact_types is not None else None,
|
||||
)
|
||||
|
||||
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
|
||||
@@ -5266,15 +5345,17 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if directives:
|
||||
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
|
||||
|
||||
# Check if the bank has any mental models
|
||||
async with pool.acquire() as conn:
|
||||
mental_model_count = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
has_mental_models = mental_model_count > 0
|
||||
if has_mental_models:
|
||||
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
|
||||
# Check if the bank has any mental models (skip check if all mental models are excluded)
|
||||
has_mental_models = False
|
||||
if not exclude_mental_models:
|
||||
async with pool.acquire() as conn:
|
||||
mental_model_count = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
has_mental_models = mental_model_count > 0
|
||||
if has_mental_models:
|
||||
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
|
||||
|
||||
# Run the agent with parent span for reflect operation (skip if called from another operation)
|
||||
if not _skip_span:
|
||||
@@ -5284,29 +5365,52 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
span_context = None
|
||||
|
||||
try:
|
||||
agent_result = await run_reflect_agent(
|
||||
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
bank_profile=profile,
|
||||
search_mental_models_fn=search_mental_models_fn,
|
||||
search_observations_fn=search_observations_fn,
|
||||
recall_fn=recall_fn,
|
||||
expand_fn=expand_fn,
|
||||
context=context,
|
||||
max_iterations=max_iterations,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
directives=directives,
|
||||
has_mental_models=has_mental_models,
|
||||
budget=effective_budget,
|
||||
max_context_tokens=max_context_tokens,
|
||||
)
|
||||
try:
|
||||
agent_result = await asyncio.wait_for(
|
||||
run_reflect_agent(
|
||||
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
bank_profile=profile,
|
||||
search_mental_models_fn=search_mental_models_fn,
|
||||
search_observations_fn=search_observations_fn,
|
||||
recall_fn=recall_fn,
|
||||
expand_fn=expand_fn,
|
||||
context=context,
|
||||
max_iterations=max_iterations,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
directives=directives,
|
||||
has_mental_models=has_mental_models,
|
||||
include_observations=include_observations,
|
||||
include_recall=include_recall,
|
||||
budget=effective_budget,
|
||||
max_context_tokens=max_context_tokens,
|
||||
),
|
||||
timeout=wall_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
total_time = time.time() - reflect_start
|
||||
logger.error(
|
||||
"[REFLECT %s] Wall-clock timeout after %.1fs (limit: %ss) for query: %.50s...",
|
||||
reflect_id,
|
||||
total_time,
|
||||
wall_timeout,
|
||||
query,
|
||||
)
|
||||
raise TimeoutError(
|
||||
f"Reflect operation timed out after {wall_timeout} seconds. "
|
||||
f"Consider reducing the budget or simplifying the query."
|
||||
)
|
||||
|
||||
total_time = time.time() - reflect_start
|
||||
logger.info(
|
||||
f"[REFLECT {reflect_id}] Complete: {len(agent_result.text)} chars, "
|
||||
f"{agent_result.iterations} iterations, {agent_result.tools_called} tool calls | {total_time:.3f}s"
|
||||
"[REFLECT %s] Complete: %d chars, %d iterations, %d tool calls | %.3fs",
|
||||
reflect_id,
|
||||
len(agent_result.text),
|
||||
agent_result.iterations,
|
||||
agent_result.tools_called,
|
||||
total_time,
|
||||
)
|
||||
|
||||
# Convert agent tool trace to ToolCallTrace objects
|
||||
@@ -6430,6 +6534,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags = mental_model.get("tags")
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
# Read reflect options from trigger (if stored)
|
||||
trigger_data = mental_model.get("trigger") or {}
|
||||
fact_types = trigger_data.get("fact_types")
|
||||
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
|
||||
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
|
||||
|
||||
# Run reflect with the source query, excluding the mental model being refreshed
|
||||
# Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh"
|
||||
reflect_result = await self.reflect_async(
|
||||
@@ -6438,7 +6548,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
request_context=request_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
exclude_mental_model_ids=[mental_model_id],
|
||||
fact_types=fact_types,
|
||||
exclude_mental_models=exclude_mental_models,
|
||||
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
|
||||
_skip_span=True,
|
||||
)
|
||||
|
||||
@@ -7407,21 +7519,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
operation_id = uuid.uuid4()
|
||||
|
||||
# Insert operation record into database
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
operation_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
json.dumps(result_metadata or {}),
|
||||
"pending",
|
||||
)
|
||||
|
||||
# Build and submit task payload
|
||||
# Build full payload before INSERT so task_payload is included atomically.
|
||||
# Previously the INSERT omitted task_payload and a separate submit_task call
|
||||
# did an UPDATE — a crash between the two left a null-payload row that the
|
||||
# worker's claim query (task_payload IS NOT NULL) could never pick up.
|
||||
full_payload = {
|
||||
"type": task_type,
|
||||
"operation_id": str(operation_id),
|
||||
@@ -7429,6 +7530,24 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
**task_payload,
|
||||
}
|
||||
|
||||
# Insert operation record with task_payload in a single atomic statement
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status, task_payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
""",
|
||||
operation_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
json.dumps(result_metadata or {}, default=_json_default),
|
||||
"pending",
|
||||
json.dumps(full_payload, default=_json_default),
|
||||
)
|
||||
|
||||
# For SyncTaskBackend: executes the task immediately.
|
||||
# For BrokerTaskBackend: does an idempotent UPDATE (payload already set above),
|
||||
# kept for symmetry and to support any future notification mechanisms.
|
||||
await self._task_backend.submit_task(full_payload)
|
||||
|
||||
logger.info(f"{operation_type} task queued for bank_id={bank_id}, operation_id={operation_id}")
|
||||
@@ -7462,7 +7581,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
contents=[dict(c) for c in contents],
|
||||
request_context=request_context,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
if result and result.contents is not None:
|
||||
contents = result.contents
|
||||
|
||||
# Validate no duplicate document_ids in the batch
|
||||
# Having duplicate document_ids causes race conditions in document upserts during parallel processing
|
||||
@@ -7758,6 +7879,15 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
Returns:
|
||||
Dict with operation_id
|
||||
"""
|
||||
# Block mental model refresh when LLM provider is "none"
|
||||
if self._llm_config.provider == "none":
|
||||
from .providers.none_llm import LLMNotAvailableError
|
||||
|
||||
raise LLMNotAvailableError(
|
||||
"Mental model refresh requires an LLM provider. Current provider is set to 'none'. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
await self._authenticate_tenant(request_context)
|
||||
|
||||
# Pre-operation validation (credit check)
|
||||
|
||||
@@ -8,7 +8,18 @@ from .anthropic_llm import AnthropicLLM
|
||||
from .claude_code_llm import ClaudeCodeLLM
|
||||
from .codex_llm import CodexLLM
|
||||
from .gemini_llm import GeminiLLM
|
||||
from .litellm_llm import LiteLLMLLM
|
||||
from .mock_llm import MockLLM
|
||||
from .none_llm import NoneLLM
|
||||
from .openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
__all__ = ["AnthropicLLM", "ClaudeCodeLLM", "CodexLLM", "GeminiLLM", "MockLLM", "OpenAICompatibleLLM"]
|
||||
__all__ = [
|
||||
"AnthropicLLM",
|
||||
"ClaudeCodeLLM",
|
||||
"CodexLLM",
|
||||
"GeminiLLM",
|
||||
"LiteLLMLLM",
|
||||
"MockLLM",
|
||||
"NoneLLM",
|
||||
"OpenAICompatibleLLM",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
LiteLLM LLM provider for universal model support.
|
||||
|
||||
This provider enables using 100+ LLM providers via the LiteLLM SDK, including:
|
||||
- AWS Bedrock (bedrock/anthropic.claude-3-5-sonnet-...)
|
||||
- Azure OpenAI (azure/gpt-4o)
|
||||
- Together AI (together_ai/meta-llama/...)
|
||||
- Any other LiteLLM-supported provider
|
||||
|
||||
Uses litellm.acompletion() for async chat completions.
|
||||
Authentication for cloud providers (e.g., AWS Bedrock via boto3 credential chain)
|
||||
is handled automatically by LiteLLM.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
||||
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
||||
from hindsight_api.metrics import get_metrics_collector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiteLLMLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider using the LiteLLM SDK for universal model support.
|
||||
|
||||
Supports any model accessible via litellm.acompletion(), including AWS Bedrock,
|
||||
Azure OpenAI, Together AI, Fireworks AI, and more.
|
||||
|
||||
Model names follow LiteLLM conventions with provider prefixes:
|
||||
- bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0
|
||||
- azure/gpt-4o
|
||||
- together_ai/meta-llama/Llama-3-70b-chat-hf
|
||||
- fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
reasoning_effort: str = "low",
|
||||
timeout: float = 300.0,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
||||
self.timeout = timeout
|
||||
self._litellm: Any = None
|
||||
|
||||
try:
|
||||
import litellm
|
||||
|
||||
self._litellm = litellm
|
||||
# Suppress LiteLLM's verbose logging
|
||||
litellm.suppress_debug_info = True # type: ignore[assignment]
|
||||
# Drop unsupported params instead of raising errors (e.g. tool_choice on some Bedrock models)
|
||||
litellm.drop_params = True # type: ignore[assignment]
|
||||
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
|
||||
logger.info(f"LiteLLM SDK initialized for model: {self.model}")
|
||||
except ImportError as e:
|
||||
raise RuntimeError("LiteLLM SDK not installed. Run: uv add litellm or pip install litellm") from e
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
try:
|
||||
test_messages = [{"role": "user", "content": "test"}]
|
||||
await self.call(
|
||||
messages=test_messages,
|
||||
max_completion_tokens=50,
|
||||
temperature=0.0,
|
||||
scope="verification",
|
||||
max_retries=0,
|
||||
)
|
||||
logger.info("LiteLLM connection verified successfully")
|
||||
except OutputTooLongError:
|
||||
# Truncation is fine for verification — it means the connection works
|
||||
logger.info("LiteLLM connection verified successfully (response truncated)")
|
||||
except Exception as e:
|
||||
logger.error(f"LiteLLM connection verification failed: {e}")
|
||||
raise RuntimeError(f"Failed to verify LiteLLM connection: {e}") from e
|
||||
|
||||
def _build_common_kwargs(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build common kwargs for litellm calls."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.base_url:
|
||||
kwargs["api_base"] = self.base_url
|
||||
if max_completion_tokens is not None:
|
||||
kwargs["max_completion_tokens"] = max_completion_tokens
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
return kwargs
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "memory",
|
||||
max_retries: int = 10,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
start_time = time.time()
|
||||
|
||||
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
|
||||
|
||||
# Add JSON schema response format if provided
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
call_kwargs["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__ if hasattr(response_format, "__name__") else "response",
|
||||
"schema": schema,
|
||||
"strict": strict_schema,
|
||||
},
|
||||
}
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await self._litellm.acompletion(**call_kwargs)
|
||||
|
||||
content = response.choices[0].message.content or ""
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
|
||||
# Check for length-limited output
|
||||
if finish_reason == "length":
|
||||
raise OutputTooLongError("LiteLLM response was truncated due to token limit")
|
||||
|
||||
if response_format is not None:
|
||||
# Strip markdown code fences if present
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
clean_content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
clean_content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
json_data = json.loads(content)
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
else:
|
||||
result = response_format.model_validate(json_data)
|
||||
else:
|
||||
result = content
|
||||
|
||||
# Extract usage
|
||||
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
|
||||
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
# Record metrics
|
||||
duration = time.time() - start_time
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import _serialize_for_span, 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=_serialize_for_span(result),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
)
|
||||
|
||||
if duration > 10.0:
|
||||
logger.info(
|
||||
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
|
||||
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
|
||||
if return_usage:
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
return result, token_usage
|
||||
return result
|
||||
|
||||
except OutputTooLongError:
|
||||
raise
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning("LiteLLM returned invalid JSON, retrying...")
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
# Fast fail on auth errors
|
||||
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
|
||||
logger.error(f"LiteLLM auth error, not retrying: {e}")
|
||||
raise
|
||||
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
# Retry on rate limits, connection errors, server errors
|
||||
is_retryable = any(
|
||||
keyword in error_str
|
||||
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
|
||||
)
|
||||
if is_retryable:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
||||
await asyncio.sleep(backoff + jitter)
|
||||
continue
|
||||
|
||||
logger.error(f"LiteLLM API error after {attempt + 1} attempts: {e}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("LiteLLM call failed after all retries")
|
||||
|
||||
async def call_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "tools",
|
||||
max_retries: int = 5,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 30.0,
|
||||
tool_choice: str | dict[str, Any] = "auto",
|
||||
) -> LLMToolCallResult:
|
||||
start_time = time.time()
|
||||
|
||||
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
|
||||
call_kwargs["tools"] = tools
|
||||
call_kwargs["tool_choice"] = tool_choice
|
||||
|
||||
last_exception = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await self._litellm.acompletion(**call_kwargs)
|
||||
|
||||
message = response.choices[0].message
|
||||
content = message.content
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
|
||||
# Extract tool calls
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
arguments = tc.function.arguments
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(arguments)
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
arguments=arguments,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract usage
|
||||
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
|
||||
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
|
||||
|
||||
# Record metrics
|
||||
duration = time.time() - start_time
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
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,
|
||||
finish_reason=finish_reason or ("tool_calls" if tool_calls else "stop"),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
|
||||
raise
|
||||
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
is_retryable = any(
|
||||
keyword in error_str
|
||||
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
|
||||
)
|
||||
if is_retryable:
|
||||
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
|
||||
continue
|
||||
|
||||
logger.error(f"LiteLLM tool call error after {attempt + 1} attempts: {e}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("LiteLLM tool call failed after all retries")
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
pass
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
No-op LLM provider for chunk-only storage mode.
|
||||
|
||||
When the LLM provider is set to "none", the system operates without any LLM dependency.
|
||||
Retain uses chunks mode (no fact extraction), and reflect/consolidation are disabled.
|
||||
This provider acts as a safety net — if any code path unexpectedly tries to call the LLM,
|
||||
it raises a clear error instead of a confusing connection failure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..llm_interface import LLMInterface
|
||||
from ..response_models import LLMToolCallResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMNotAvailableError(Exception):
|
||||
"""Raised when an operation requires an LLM but the provider is set to 'none'."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NoneLLM(LLMInterface):
|
||||
"""
|
||||
No-op LLM provider that rejects all LLM calls.
|
||||
|
||||
Used when HINDSIGHT_API_LLM_PROVIDER=none to run Hindsight as a chunk store
|
||||
with semantic search but without LLM-based features (fact extraction, reflect,
|
||||
consolidation).
|
||||
"""
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
"""No-op — no LLM connection to verify."""
|
||||
logger.debug("NoneLLM: no LLM connection to verify (provider=none)")
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "memory",
|
||||
max_retries: int = 10,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Raise LLMNotAvailableError — no LLM is configured."""
|
||||
raise LLMNotAvailableError(
|
||||
"LLM provider is set to 'none'. This operation requires an LLM. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
async def call_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "tools",
|
||||
max_retries: int = 5,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 30.0,
|
||||
tool_choice: str | dict[str, Any] = "auto",
|
||||
) -> LLMToolCallResult:
|
||||
"""Raise LLMNotAvailableError — no LLM is configured."""
|
||||
raise LLMNotAvailableError(
|
||||
"LLM provider is set to 'none'. This operation requires an LLM. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""No-op — nothing to clean up."""
|
||||
pass
|
||||
@@ -24,6 +24,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
||||
@@ -39,6 +40,25 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_LLM_SEED = 4242
|
||||
|
||||
|
||||
def _strip_code_fences(content: str) -> str:
|
||||
"""Strip markdown code fences from LLM response if present.
|
||||
|
||||
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
|
||||
wrap JSON responses in ```json ... ``` fences even when json_object
|
||||
response format is requested. This strips the fences while preserving
|
||||
the JSON content inside. Returns the original content unchanged if
|
||||
no fences are detected.
|
||||
"""
|
||||
if "```" not in content:
|
||||
return content
|
||||
try:
|
||||
if "```json" in content:
|
||||
return content.split("```json")[1].split("```")[0].strip()
|
||||
return content.split("```")[1].split("```")[0].strip()
|
||||
except (IndexError, ValueError):
|
||||
return content
|
||||
|
||||
|
||||
class OpenAICompatibleLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider for OpenAI-compatible APIs.
|
||||
@@ -108,10 +128,18 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
# Get timeout config
|
||||
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
|
||||
|
||||
# Create OpenAI client
|
||||
# Create OpenAI client — extract query params from base_url (e.g. Azure api-version)
|
||||
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
|
||||
if self.base_url:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
parsed = urlparse(self.base_url)
|
||||
if parsed.query:
|
||||
clean_url = urlunparse(parsed._replace(query=""))
|
||||
client_kwargs["base_url"] = clean_url
|
||||
default_query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
||||
client_kwargs["default_query"] = default_query
|
||||
self.base_url = clean_url
|
||||
else:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
if self.timeout:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
|
||||
@@ -313,20 +341,14 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
if len(content) < original_len:
|
||||
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
|
||||
|
||||
# For local models, they may wrap JSON in markdown code blocks
|
||||
if self.provider in ("lmstudio", "ollama"):
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
clean_content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
clean_content = content.split("```")[1].split("```")[0].strip()
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content
|
||||
json_data = json.loads(content)
|
||||
else:
|
||||
# Log raw LLM response for debugging JSON parse issues
|
||||
# Strip markdown code fences if present — any provider may
|
||||
# produce these (confirmed with MiniMax, some Ollama models,
|
||||
# Claude via proxies). No-op when content is already bare JSON.
|
||||
clean_content = _strip_code_fences(content)
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content in case stripping was wrong
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
@@ -721,26 +743,33 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
result = response.json()
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
# Parse JSON response
|
||||
# Strip markdown code fences if present (safety net —
|
||||
# Ollama with schema enforcement usually returns bare JSON,
|
||||
# but some models may still wrap in fences)
|
||||
clean_content = _strip_code_fences(content)
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: ollama/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to raw content
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: ollama/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
# Extract token usage from Ollama response
|
||||
duration = time.time() - start_time
|
||||
|
||||
@@ -316,6 +316,8 @@ async def run_reflect_agent(
|
||||
response_schema: dict | None = None,
|
||||
directives: list[dict[str, Any]] | None = None,
|
||||
has_mental_models: bool = False,
|
||||
include_observations: bool = True,
|
||||
include_recall: bool = True,
|
||||
budget: str | None = None,
|
||||
max_context_tokens: int = 100_000,
|
||||
) -> ReflectAgentResult:
|
||||
@@ -355,7 +357,14 @@ async def run_reflect_agent(
|
||||
directive_rules = _extract_directive_rules(directives) if directives else None
|
||||
|
||||
# Get tools for this agent (with directive compliance field if directives exist)
|
||||
tools = get_reflect_tools(directive_rules=directive_rules)
|
||||
tools = get_reflect_tools(
|
||||
directive_rules=directive_rules,
|
||||
include_mental_models=has_mental_models,
|
||||
include_observations=include_observations,
|
||||
include_recall=include_recall,
|
||||
)
|
||||
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
|
||||
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
|
||||
|
||||
# Build initial messages (directives are injected into system prompt at START and END)
|
||||
system_prompt = build_system_prompt_for_tools(
|
||||
@@ -538,19 +547,18 @@ async def run_reflect_agent(
|
||||
llm_start = time.time()
|
||||
|
||||
# Determine tool_choice for this iteration.
|
||||
# Force the full hierarchical retrieval path before allowing auto:
|
||||
# With mental models:
|
||||
# 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto
|
||||
# Without mental models:
|
||||
# 0 → search_observations, 1 → recall, 2+ → auto
|
||||
if iteration == 0 and has_mental_models:
|
||||
iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}}
|
||||
elif iteration == 0:
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
|
||||
elif iteration == 1 and has_mental_models:
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
|
||||
elif iteration == 1 or (iteration == 2 and has_mental_models):
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
|
||||
# Force the full hierarchical retrieval path (only for enabled tools) before allowing auto.
|
||||
# Build the forced sequence from the tools that are actually enabled.
|
||||
forced_sequence = []
|
||||
if has_mental_models:
|
||||
forced_sequence.append("search_mental_models")
|
||||
if include_observations:
|
||||
forced_sequence.append("search_observations")
|
||||
if include_recall:
|
||||
forced_sequence.append("recall")
|
||||
|
||||
if iteration < len(forced_sequence):
|
||||
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
|
||||
else:
|
||||
iter_tool_choice = "auto"
|
||||
|
||||
@@ -769,7 +777,17 @@ async def run_reflect_agent(
|
||||
# Execute other tools in parallel (exclude done tool in all its format variants)
|
||||
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
|
||||
if other_tools:
|
||||
# Add assistant message with tool calls
|
||||
# Partition into enabled vs hallucinated (not in enabled_tools set)
|
||||
allowed_tools = []
|
||||
hallucinated_tools = []
|
||||
for tc in other_tools:
|
||||
norm = _normalize_tool_name(tc.name)
|
||||
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
|
||||
hallucinated_tools.append(tc)
|
||||
else:
|
||||
allowed_tools.append(tc)
|
||||
|
||||
# Build assistant message with all tool calls (LLM requires them for history)
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -777,6 +795,23 @@ async def run_reflect_agent(
|
||||
}
|
||||
)
|
||||
|
||||
# Immediately reject hallucinated tool calls without adding to trace
|
||||
for tc in hallucinated_tools:
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.name,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
other_tools = allowed_tools
|
||||
|
||||
# Execute tools in parallel
|
||||
tool_tasks = [
|
||||
_execute_tool_with_timing(
|
||||
@@ -785,6 +820,7 @@ async def run_reflect_agent(
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
for tc in other_tools
|
||||
]
|
||||
@@ -974,6 +1010,7 @@ async def _execute_tool_with_timing(
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
enabled_tools: frozenset[str] | None = None,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Execute a tool call and return result with timing."""
|
||||
from hindsight_api.tracing import get_tracer
|
||||
@@ -1007,6 +1044,7 @@ async def _execute_tool_with_timing(
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
|
||||
# Set success attributes
|
||||
@@ -1046,11 +1084,16 @@ async def _execute_tool(
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
enabled_tools: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single tool by name."""
|
||||
# Normalize tool name for various LLM output formats
|
||||
tool_name = _normalize_tool_name(tool_name)
|
||||
|
||||
# Guard against LLMs hallucinating calls to tools that were not provided
|
||||
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
|
||||
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
|
||||
|
||||
if tool_name == "search_mental_models":
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
|
||||
@@ -134,9 +134,10 @@ async def tool_search_observations(
|
||||
tag_groups: "list | None" = None,
|
||||
last_consolidated_at: datetime | None = None,
|
||||
pending_consolidation: int = 0,
|
||||
source_facts_max_tokens: int = -1,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search consolidated observations using recall with include_source_facts.
|
||||
Search consolidated observations using recall.
|
||||
|
||||
Observations are auto-generated from memories. Returns freshness info
|
||||
so the agent knows if it should also verify with recall().
|
||||
@@ -151,10 +152,16 @@ async def tool_search_observations(
|
||||
tags_match: How to match tags - "any" (OR), "all" (AND)
|
||||
last_consolidated_at: When consolidation last ran (for staleness check)
|
||||
pending_consolidation: Number of memories waiting to be consolidated
|
||||
source_facts_max_tokens: Token budget for source facts (-1 = disabled, 0+ = enabled with limit)
|
||||
|
||||
Returns:
|
||||
Dict with matching observations including freshness info and source memories
|
||||
"""
|
||||
include_source_facts = source_facts_max_tokens != -1
|
||||
recall_kwargs: dict[str, Any] = {}
|
||||
if include_source_facts and source_facts_max_tokens > 0:
|
||||
recall_kwargs["max_source_facts_tokens"] = source_facts_max_tokens
|
||||
|
||||
result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
@@ -165,10 +172,10 @@ async def tool_search_observations(
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=-1, # No token limit — include all source facts
|
||||
include_source_facts=include_source_facts,
|
||||
_connection_budget=1,
|
||||
_quiet=True,
|
||||
**recall_kwargs,
|
||||
)
|
||||
|
||||
is_stale = pending_consolidation > 0
|
||||
@@ -200,6 +207,7 @@ async def tool_recall(
|
||||
tag_groups: "list | None" = None,
|
||||
connection_budget: int = 1,
|
||||
max_chunk_tokens: int = 1000,
|
||||
fact_types: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search memories using TEMPR retrieval.
|
||||
@@ -217,15 +225,18 @@ async def tool_recall(
|
||||
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
|
||||
connection_budget: Max DB connections for this recall (default 1 for internal ops)
|
||||
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
|
||||
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
|
||||
|
||||
Returns:
|
||||
Dict with list of matching memories including raw chunk text
|
||||
"""
|
||||
# Only world/experience are valid for raw recall (observation is handled by search_observations)
|
||||
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
|
||||
include_chunks = True
|
||||
result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
fact_type=["experience", "world"],
|
||||
fact_type=recall_fact_type,
|
||||
max_tokens=max_tokens,
|
||||
enable_trace=False,
|
||||
request_context=request_context,
|
||||
|
||||
@@ -227,7 +227,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
|
||||
def get_reflect_tools(
|
||||
directive_rules: list[str] | None = None,
|
||||
include_mental_models: bool = True,
|
||||
include_observations: bool = True,
|
||||
include_recall: bool = True,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get the list of tools for the reflect agent.
|
||||
|
||||
@@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
|
||||
Args:
|
||||
directive_rules: Optional list of directive rule strings. If provided,
|
||||
the done() tool will require directive compliance confirmation.
|
||||
include_mental_models: Whether to include the search_mental_models tool.
|
||||
include_observations: Whether to include the search_observations tool.
|
||||
include_recall: Whether to include the recall tool.
|
||||
|
||||
Returns:
|
||||
List of tool definitions in OpenAI format
|
||||
"""
|
||||
tools = [
|
||||
TOOL_SEARCH_MENTAL_MODELS,
|
||||
TOOL_SEARCH_OBSERVATIONS,
|
||||
TOOL_RECALL,
|
||||
TOOL_EXPAND,
|
||||
]
|
||||
tools = []
|
||||
|
||||
if include_mental_models:
|
||||
tools.append(TOOL_SEARCH_MENTAL_MODELS)
|
||||
if include_observations:
|
||||
tools.append(TOOL_SEARCH_OBSERVATIONS)
|
||||
if include_recall:
|
||||
tools.append(TOOL_RECALL)
|
||||
|
||||
tools.append(TOOL_EXPAND)
|
||||
|
||||
# Use directive-aware done tool if directives are present
|
||||
if directive_rules:
|
||||
|
||||
@@ -8,7 +8,7 @@ API stability even if internal models change.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# Valid fact types for recall operations (excludes 'opinion' which is deprecated)
|
||||
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "observation"])
|
||||
@@ -159,6 +159,19 @@ class MemoryFact(BaseModel):
|
||||
mentioned_at: str | None = Field(None, description="ISO format date when the fact was mentioned/learned")
|
||||
document_id: str | None = Field(None, description="ID of the document this memory belongs to")
|
||||
metadata: dict[str, str] | None = Field(None, description="User-defined metadata")
|
||||
|
||||
@field_validator("metadata", mode="before")
|
||||
@classmethod
|
||||
def parse_metadata(cls, v: Any) -> dict[str, str] | None:
|
||||
"""Parse metadata from JSON string if needed (asyncpg may return JSONB as str)."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
chunk_id: str | None = Field(
|
||||
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
|
||||
)
|
||||
|
||||
@@ -1083,30 +1083,16 @@ async def _extract_facts_from_chunk(
|
||||
logger.warning(f"Skipping fact {i}: missing 'what' field")
|
||||
continue
|
||||
|
||||
# Critical field: fact_type
|
||||
# LLM uses "assistant" but we convert to "experience" for storage
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience" for storage
|
||||
if fact_type == "assistant":
|
||||
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
|
||||
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
|
||||
raw_fact_type = llm_fact.get("fact_type")
|
||||
if raw_fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type (after conversion)
|
||||
if fact_type not in ["world", "experience", "opinion"]:
|
||||
# Try to fix common mistakes - check if they swapped fact_type and fact_kind
|
||||
fact_kind = llm_fact.get("fact_kind")
|
||||
if fact_kind == "assistant":
|
||||
fact_type = "experience"
|
||||
elif fact_kind in ["world", "experience", "opinion"]:
|
||||
fact_type = fact_kind
|
||||
else:
|
||||
# Default to 'world' if we can't determine
|
||||
fact_type = "world"
|
||||
logger.warning(
|
||||
f"Fact {i}: defaulting to fact_type='world' "
|
||||
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
|
||||
)
|
||||
elif raw_fact_type == "world":
|
||||
fact_type = "world"
|
||||
else:
|
||||
raw_fact_kind = llm_fact.get("fact_kind")
|
||||
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
|
||||
|
||||
# Get fact_kind for temporal handling (but don't store it)
|
||||
fact_kind = llm_fact.get("fact_kind", "conversation")
|
||||
@@ -1754,23 +1740,17 @@ async def extract_facts_from_contents_batch_api(
|
||||
who = get_value("who")
|
||||
why = get_value("why")
|
||||
|
||||
# Critical field: fact_type
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience"
|
||||
if fact_type == "assistant":
|
||||
# Critical field: fact_type — only "assistant" maps to "experience", everything else is "world"
|
||||
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
|
||||
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
|
||||
raw_fact_type = llm_fact.get("fact_type")
|
||||
if raw_fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type
|
||||
if fact_type not in ["world", "experience", "opinion"]:
|
||||
fact_kind = llm_fact.get("fact_kind")
|
||||
if fact_kind == "assistant":
|
||||
fact_type = "experience"
|
||||
elif fact_kind in ["world", "experience", "opinion"]:
|
||||
fact_type = fact_kind
|
||||
else:
|
||||
fact_type = "world"
|
||||
elif raw_fact_type == "world":
|
||||
fact_type = "world"
|
||||
else:
|
||||
raw_fact_kind = llm_fact.get("fact_kind")
|
||||
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
|
||||
|
||||
# Build combined fact text
|
||||
combined_parts = [what]
|
||||
@@ -1933,7 +1913,7 @@ async def extract_facts_from_contents_batch_api(
|
||||
for fact_from_llm in chunk_facts:
|
||||
extracted_fact = ExtractedFactType(
|
||||
fact_text=fact_from_llm.fact,
|
||||
fact_type=fact_from_llm.fact_type,
|
||||
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
|
||||
entities=[e.text for e in (fact_from_llm.entities or [])],
|
||||
occurred_start=_parse_datetime(fact_from_llm.occurred_start) if fact_from_llm.occurred_start else None,
|
||||
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
|
||||
@@ -2110,7 +2090,7 @@ async def extract_facts_from_contents(
|
||||
# mentioned_at is always the event_date (when the conversation/document occurred)
|
||||
extracted_fact = ExtractedFactType(
|
||||
fact_text=fact_from_llm.fact,
|
||||
fact_type=fact_from_llm.fact_type,
|
||||
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
|
||||
entities=[e.text for e in (fact_from_llm.entities or [])],
|
||||
# occurred_start/end: from LLM only, leave None if not provided
|
||||
occurred_start=_parse_datetime(fact_from_llm.occurred_start)
|
||||
|
||||
@@ -81,9 +81,15 @@ async def insert_facts_batch(
|
||||
if fact.entities:
|
||||
signal_parts.extend(e.name for e in fact.entities)
|
||||
if fact.occurred_start:
|
||||
signal_parts.append(fact.occurred_start.strftime("%B %-d %Y"))
|
||||
try:
|
||||
signal_parts.append(fact.occurred_start.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if fact.occurred_end and fact.occurred_end != fact.occurred_start:
|
||||
signal_parts.append(fact.occurred_end.strftime("%B %-d %Y"))
|
||||
try:
|
||||
signal_parts.append(fact.occurred_end.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
text_signals_list.append(" ".join(signal_parts) if signal_parts else None)
|
||||
|
||||
# Batch insert all facts
|
||||
|
||||
@@ -292,6 +292,10 @@ async def retain_batch(
|
||||
pf.document_id = None
|
||||
pf.chunk_id = None
|
||||
|
||||
# Discard any leftover pending stats from a previous failed attempt so
|
||||
# retries don't double-count or accumulate unbounded state.
|
||||
entity_resolver.discard_pending_stats()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
async with conn.transaction():
|
||||
# Handle document tracking for all documents
|
||||
|
||||
@@ -231,7 +231,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
|
||||
mu.mentioned_at, mu.fact_type,
|
||||
mu.document_id, mu.chunk_id, mu.tags,
|
||||
mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
ml.weight, ml.link_type, ml.from_unit_id
|
||||
FROM {fq_table("memory_links")} ml
|
||||
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
|
||||
|
||||
@@ -449,7 +449,7 @@ async def fetch_memory_units_by_ids(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags, metadata
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND fact_type = $2
|
||||
|
||||
@@ -153,6 +153,8 @@ class CrossEncoderReranker:
|
||||
|
||||
# Normalize scores using sigmoid to [0, 1] range
|
||||
# Cross-encoder returns logits which can be negative
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
def sigmoid(x):
|
||||
@@ -163,11 +165,20 @@ class CrossEncoderReranker:
|
||||
# Create ScoredResult objects with cross-encoder scores
|
||||
scored_results = []
|
||||
for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores):
|
||||
# Sanitize NaN scores (cross-encoder can return NaN for certain inputs).
|
||||
# NaN propagates through all downstream scoring and Pydantic serializes
|
||||
# NaN as JSON null, which breaks clients expecting numeric values.
|
||||
raw = float(raw_score)
|
||||
norm = float(norm_score)
|
||||
if math.isnan(raw):
|
||||
raw = 0.0
|
||||
if math.isnan(norm):
|
||||
norm = 0.0
|
||||
scored_result = ScoredResult(
|
||||
candidate=candidate,
|
||||
cross_encoder_score=float(raw_score),
|
||||
cross_encoder_score_normalized=float(norm_score),
|
||||
weight=float(norm_score), # Initial weight is just cross-encoder score
|
||||
cross_encoder_score=raw,
|
||||
cross_encoder_score_normalized=norm,
|
||||
weight=norm, # Initial weight is just cross-encoder score
|
||||
)
|
||||
scored_results.append(scored_result)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Implements:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
@@ -26,6 +27,15 @@ from .types import MPFPTimings, RetrievalResult
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def tokenize_query(query_text: str) -> list[str]:
|
||||
"""Normalize query text and split into BM25 tokens.
|
||||
|
||||
Strips punctuation, lowercases, and splits on whitespace.
|
||||
Returns an empty list when the query contains no word characters.
|
||||
"""
|
||||
return re.sub(r"[^\w\s]", " ", query_text.lower()).split()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParallelRetrievalResult:
|
||||
"""Result from parallel retrieval across all methods."""
|
||||
@@ -129,30 +139,31 @@ async def retrieve_semantic_bm25_combined(
|
||||
Returns:
|
||||
Dict mapping fact_type -> (semantic_results, bm25_results)
|
||||
"""
|
||||
import re
|
||||
|
||||
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
|
||||
|
||||
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
|
||||
tokens = [token for token in sanitized_text.split() if token]
|
||||
tokens = tokenize_query(query_text)
|
||||
|
||||
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
|
||||
hnsw_fetch = max(limit * 5, 100)
|
||||
|
||||
cols = (
|
||||
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
|
||||
"fact_type, document_id, chunk_id, tags"
|
||||
"fact_type, document_id, chunk_id, tags, metadata"
|
||||
)
|
||||
table = fq_table("memory_units")
|
||||
|
||||
# --- Parameter layout ---
|
||||
# $1 = query_emb_str (semantic arms)
|
||||
# $2 = bank_id
|
||||
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
|
||||
# $4 = bm25_text (only when tokens present)
|
||||
# $N = tags (N=4 when no tokens, N=5 when tokens present)
|
||||
# $M+ = tag_groups params (one per leaf, starting after tags param)
|
||||
tags_param_idx = 5 if tokens else 4
|
||||
# When tokens present:
|
||||
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
|
||||
# $4 = bm25_text
|
||||
# $5 = tags (if present)
|
||||
# $6+ = tag_groups params (one per leaf)
|
||||
# When no tokens ($3 is skipped — not included in params to avoid type inference gap):
|
||||
# $3 = tags (if present)
|
||||
# $4+ = tag_groups params (one per leaf)
|
||||
tags_param_idx = 5 if tokens else 3
|
||||
tags_clause = build_tags_where_clause_simple(tags, tags_param_idx, match=tags_match)
|
||||
|
||||
# tag_groups params start immediately after the tags param slot
|
||||
@@ -222,9 +233,10 @@ async def retrieve_semantic_bm25_combined(
|
||||
|
||||
query = "\nUNION ALL\n".join(arms)
|
||||
|
||||
params: list = [query_emb_str, bank_id, limit]
|
||||
params: list = [query_emb_str, bank_id]
|
||||
if tokens:
|
||||
params.append(bm25_text_param)
|
||||
params.append(limit) # $3: BM25 LIMIT (only referenced when tokens are present)
|
||||
params.append(bm25_text_param) # $4
|
||||
if tags:
|
||||
params.append(tags)
|
||||
params.extend(groups_params)
|
||||
@@ -331,7 +343,7 @@ async def retrieve_temporal_combined(
|
||||
{groups_clause}
|
||||
),
|
||||
sim_ranked AS (
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity,
|
||||
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
|
||||
FROM date_ranked dr
|
||||
@@ -339,7 +351,7 @@ async def retrieve_temporal_combined(
|
||||
WHERE dr.rn <= 50
|
||||
AND (1 - (mu.embedding <=> $1::vector)) >= $6
|
||||
)
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, similarity
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, metadata, similarity
|
||||
FROM sim_ranked
|
||||
WHERE sim_rn <= 10
|
||||
""",
|
||||
@@ -437,7 +449,7 @@ async def retrieve_temporal_combined(
|
||||
# bank_id on memory_units lets the planner use idx_memory_units_bank_fact_type.
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
l.weight, l.link_type,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM unnest($2::uuid[]) AS src(from_unit_id)
|
||||
|
||||
@@ -47,6 +47,7 @@ class RetrievalResult:
|
||||
document_id: str | None = None
|
||||
chunk_id: str | None = None
|
||||
tags: list[str] | None = None # Visibility scope tags
|
||||
metadata: dict[str, str] | None = None # User-provided metadata
|
||||
|
||||
# Retrieval-specific scores (only one will be set depending on retrieval method)
|
||||
similarity: float | None = None # Semantic retrieval
|
||||
@@ -70,6 +71,7 @@ class RetrievalResult:
|
||||
document_id=row.get("document_id"),
|
||||
chunk_id=row.get("chunk_id"),
|
||||
tags=row.get("tags"),
|
||||
metadata=row.get("metadata"),
|
||||
similarity=row.get("similarity"),
|
||||
bm25_score=row.get("bm25_score"),
|
||||
activation=row.get("activation"),
|
||||
@@ -153,6 +155,7 @@ class ScoredResult:
|
||||
"document_id": self.retrieval.document_id,
|
||||
"chunk_id": self.retrieval.chunk_id,
|
||||
"tags": self.retrieval.tags,
|
||||
"metadata": self.retrieval.metadata,
|
||||
"semantic_similarity": self.retrieval.similarity,
|
||||
"bm25_score": self.retrieval.bm25_score,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
|
||||
from hindsight_api.engine.response_models import ReflectResult
|
||||
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
@@ -25,17 +26,51 @@ class OperationValidationError(Exception):
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of an operation validation."""
|
||||
"""Result of an operation validation.
|
||||
|
||||
Validators return this to accept or reject an operation. When accepting,
|
||||
validators can optionally return modified data that the engine will use
|
||||
instead of the original request parameters. This enables context enrichment
|
||||
(e.g., injecting tags or tag_groups).
|
||||
"""
|
||||
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
status_code: int = 403 # Default to Forbidden
|
||||
# Optional enrichment fields — returned by validator, used by engine if present.
|
||||
# None means "no modification" (engine uses original values).
|
||||
contents: list[dict] | None = None # Enriched retain contents (e.g., injected tags/strategy)
|
||||
tags: list[str] | None = None # Enriched recall tags
|
||||
tags_match: "TagsMatch | None" = None # Enriched recall tags match mode
|
||||
tag_groups: "list[TagGroup] | None" = None # Enriched recall tag_groups
|
||||
|
||||
@classmethod
|
||||
def accept(cls) -> "ValidationResult":
|
||||
"""Create an accepted validation result."""
|
||||
"""Create an accepted validation result (no enrichment)."""
|
||||
return cls(allowed=True)
|
||||
|
||||
@classmethod
|
||||
def accept_with(
|
||||
cls,
|
||||
*,
|
||||
contents: list[dict] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: "TagsMatch | None" = None,
|
||||
tag_groups: "list[TagGroup] | None" = None,
|
||||
) -> "ValidationResult":
|
||||
"""Create an accepted validation result with enriched data.
|
||||
|
||||
The engine will use the returned values instead of the original request
|
||||
parameters. Only non-None fields are applied; None means "keep original".
|
||||
"""
|
||||
return cls(
|
||||
allowed=True,
|
||||
contents=contents,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
|
||||
"""Create a rejected validation result with a reason and HTTP status code."""
|
||||
@@ -52,10 +87,12 @@ class RetainContext:
|
||||
"""Context for a retain operation validation (pre-operation).
|
||||
|
||||
Contains ALL user-provided parameters for the retain operation.
|
||||
To enrich contents (e.g., inject tags or strategy), return them
|
||||
via ValidationResult.accept_with(contents=...).
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
contents: list[dict] # List of {content, context, event_date, document_id}
|
||||
contents: list[dict] # List of {content, context, event_date, document_id, tags, strategy}
|
||||
request_context: "RequestContext"
|
||||
document_id: str | None = None
|
||||
fact_type_override: str | None = None
|
||||
@@ -67,6 +104,8 @@ class RecallContext:
|
||||
"""Context for a recall operation validation (pre-operation).
|
||||
|
||||
Contains ALL user-provided parameters for the recall operation.
|
||||
To enrich tag filters (e.g., inject tag_groups), return them
|
||||
via ValidationResult.accept_with(tag_groups=...).
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
@@ -81,6 +120,9 @@ class RecallContext:
|
||||
max_entity_tokens: int = 500
|
||||
include_chunks: bool = False
|
||||
max_chunk_tokens: int = 8192
|
||||
tags: list[str] | None = None
|
||||
tags_match: "TagsMatch" = "any"
|
||||
tag_groups: "list[TagGroup] | None" = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -13,6 +13,7 @@ Stop with Ctrl+C.
|
||||
import argparse
|
||||
import asyncio
|
||||
import atexit
|
||||
import dataclasses
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
@@ -152,178 +153,7 @@ def main():
|
||||
# Configure Python logging based on log level
|
||||
# Update config with CLI override if provided
|
||||
if args.log_level != config.log_level:
|
||||
config = HindsightConfig(
|
||||
database_url=config.database_url,
|
||||
database_schema=config.database_schema,
|
||||
vector_extension=config.vector_extension,
|
||||
text_search_extension=config.text_search_extension,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
llm_base_url=config.llm_base_url,
|
||||
llm_max_concurrent=config.llm_max_concurrent,
|
||||
llm_max_retries=config.llm_max_retries,
|
||||
llm_initial_backoff=config.llm_initial_backoff,
|
||||
llm_max_backoff=config.llm_max_backoff,
|
||||
llm_timeout=config.llm_timeout,
|
||||
llm_groq_service_tier=config.llm_groq_service_tier,
|
||||
llm_openai_service_tier=config.llm_openai_service_tier,
|
||||
llm_vertexai_project_id=config.llm_vertexai_project_id,
|
||||
llm_vertexai_region=config.llm_vertexai_region,
|
||||
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
|
||||
llm_gemini_safety_settings=config.llm_gemini_safety_settings,
|
||||
retain_llm_provider=config.retain_llm_provider,
|
||||
retain_llm_api_key=config.retain_llm_api_key,
|
||||
retain_llm_model=config.retain_llm_model,
|
||||
retain_llm_base_url=config.retain_llm_base_url,
|
||||
retain_llm_max_concurrent=config.retain_llm_max_concurrent,
|
||||
retain_llm_max_retries=config.retain_llm_max_retries,
|
||||
retain_llm_initial_backoff=config.retain_llm_initial_backoff,
|
||||
retain_llm_max_backoff=config.retain_llm_max_backoff,
|
||||
retain_llm_timeout=config.retain_llm_timeout,
|
||||
reflect_llm_provider=config.reflect_llm_provider,
|
||||
reflect_llm_api_key=config.reflect_llm_api_key,
|
||||
reflect_llm_model=config.reflect_llm_model,
|
||||
reflect_llm_base_url=config.reflect_llm_base_url,
|
||||
reflect_llm_max_concurrent=config.reflect_llm_max_concurrent,
|
||||
reflect_llm_max_retries=config.reflect_llm_max_retries,
|
||||
reflect_llm_initial_backoff=config.reflect_llm_initial_backoff,
|
||||
reflect_llm_max_backoff=config.reflect_llm_max_backoff,
|
||||
reflect_llm_timeout=config.reflect_llm_timeout,
|
||||
consolidation_llm_provider=config.consolidation_llm_provider,
|
||||
consolidation_llm_api_key=config.consolidation_llm_api_key,
|
||||
consolidation_llm_model=config.consolidation_llm_model,
|
||||
consolidation_llm_base_url=config.consolidation_llm_base_url,
|
||||
consolidation_llm_max_concurrent=config.consolidation_llm_max_concurrent,
|
||||
consolidation_llm_max_retries=config.consolidation_llm_max_retries,
|
||||
consolidation_llm_initial_backoff=config.consolidation_llm_initial_backoff,
|
||||
consolidation_llm_max_backoff=config.consolidation_llm_max_backoff,
|
||||
consolidation_llm_timeout=config.consolidation_llm_timeout,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
|
||||
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
embeddings_openai_base_url=config.embeddings_openai_base_url,
|
||||
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
|
||||
embeddings_cohere_model=config.embeddings_cohere_model,
|
||||
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
|
||||
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
|
||||
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
|
||||
embeddings_litellm_model=config.embeddings_litellm_model,
|
||||
embeddings_litellm_sdk_api_key=config.embeddings_litellm_sdk_api_key,
|
||||
embeddings_litellm_sdk_model=config.embeddings_litellm_sdk_model,
|
||||
embeddings_litellm_sdk_api_base=config.embeddings_litellm_sdk_api_base,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
|
||||
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
|
||||
reranker_local_fp16=config.reranker_local_fp16,
|
||||
reranker_local_bucket_batching=config.reranker_local_bucket_batching,
|
||||
reranker_local_batch_size=config.reranker_local_batch_size,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
reranker_max_candidates=config.reranker_max_candidates,
|
||||
reranker_cohere_api_key=config.reranker_cohere_api_key,
|
||||
reranker_cohere_model=config.reranker_cohere_model,
|
||||
reranker_cohere_base_url=config.reranker_cohere_base_url,
|
||||
reranker_litellm_api_base=config.reranker_litellm_api_base,
|
||||
reranker_litellm_api_key=config.reranker_litellm_api_key,
|
||||
reranker_litellm_model=config.reranker_litellm_model,
|
||||
reranker_litellm_max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
|
||||
reranker_litellm_sdk_api_key=config.reranker_litellm_sdk_api_key,
|
||||
reranker_litellm_sdk_model=config.reranker_litellm_sdk_model,
|
||||
reranker_litellm_sdk_api_base=config.reranker_litellm_sdk_api_base,
|
||||
reranker_zeroentropy_api_key=config.reranker_zeroentropy_api_key,
|
||||
reranker_zeroentropy_model=config.reranker_zeroentropy_model,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
base_path=config.base_path,
|
||||
log_level=args.log_level,
|
||||
log_format=config.log_format,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
mcp_enabled_tools=config.mcp_enabled_tools,
|
||||
enable_bank_config_api=config.enable_bank_config_api,
|
||||
graph_retriever=config.graph_retriever,
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
recall_max_concurrent=config.recall_max_concurrent,
|
||||
recall_connection_budget=config.recall_connection_budget,
|
||||
recall_max_query_tokens=config.recall_max_query_tokens,
|
||||
retain_max_completion_tokens=config.retain_max_completion_tokens,
|
||||
retain_chunk_size=config.retain_chunk_size,
|
||||
retain_extract_causal_links=config.retain_extract_causal_links,
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_mission=config.retain_mission,
|
||||
retain_custom_instructions=config.retain_custom_instructions,
|
||||
retain_default_strategy=config.retain_default_strategy,
|
||||
retain_strategies=config.retain_strategies,
|
||||
retain_batch_tokens=config.retain_batch_tokens,
|
||||
retain_entity_lookup=config.retain_entity_lookup,
|
||||
retain_batch_enabled=config.retain_batch_enabled,
|
||||
retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds,
|
||||
file_storage_type=config.file_storage_type,
|
||||
file_storage_s3_bucket=config.file_storage_s3_bucket,
|
||||
file_storage_s3_region=config.file_storage_s3_region,
|
||||
file_storage_s3_endpoint=config.file_storage_s3_endpoint,
|
||||
file_storage_s3_access_key_id=config.file_storage_s3_access_key_id,
|
||||
file_storage_s3_secret_access_key=config.file_storage_s3_secret_access_key,
|
||||
file_storage_gcs_bucket=config.file_storage_gcs_bucket,
|
||||
file_storage_gcs_service_account_key=config.file_storage_gcs_service_account_key,
|
||||
file_storage_azure_container=config.file_storage_azure_container,
|
||||
file_storage_azure_account_name=config.file_storage_azure_account_name,
|
||||
file_storage_azure_account_key=config.file_storage_azure_account_key,
|
||||
file_parser=config.file_parser,
|
||||
file_parser_allowlist=config.file_parser_allowlist,
|
||||
file_parser_iris_token=config.file_parser_iris_token,
|
||||
file_parser_iris_org_id=config.file_parser_iris_org_id,
|
||||
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
|
||||
file_conversion_max_batch_size=config.file_conversion_max_batch_size,
|
||||
enable_file_upload_api=config.enable_file_upload_api,
|
||||
file_delete_after_retain=config.file_delete_after_retain,
|
||||
enable_observations=config.enable_observations,
|
||||
enable_observation_history=config.enable_observation_history,
|
||||
enable_mental_model_history=config.enable_mental_model_history,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
|
||||
consolidation_max_tokens=config.consolidation_max_tokens,
|
||||
consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens,
|
||||
consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
observations_mission=config.observations_mission,
|
||||
entity_labels=config.entity_labels,
|
||||
entities_allow_free_form=config.entities_allow_free_form,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
db_pool_min_size=config.db_pool_min_size,
|
||||
db_pool_max_size=config.db_pool_max_size,
|
||||
db_command_timeout=config.db_command_timeout,
|
||||
db_acquire_timeout=config.db_acquire_timeout,
|
||||
worker_enabled=config.worker_enabled,
|
||||
worker_id=config.worker_id,
|
||||
worker_poll_interval_ms=config.worker_poll_interval_ms,
|
||||
worker_max_retries=config.worker_max_retries,
|
||||
worker_http_port=config.worker_http_port,
|
||||
worker_max_slots=config.worker_max_slots,
|
||||
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
reflect_max_context_tokens=config.reflect_max_context_tokens,
|
||||
reflect_mission=config.reflect_mission,
|
||||
disposition_skepticism=config.disposition_skepticism,
|
||||
disposition_literalism=config.disposition_literalism,
|
||||
disposition_empathy=config.disposition_empathy,
|
||||
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,
|
||||
webhook_url=config.webhook_url,
|
||||
webhook_secret=config.webhook_secret,
|
||||
webhook_event_types=config.webhook_event_types,
|
||||
webhook_delivery_poll_interval_seconds=config.webhook_delivery_poll_interval_seconds,
|
||||
)
|
||||
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
config.log_config()
|
||||
@@ -381,15 +211,27 @@ def main():
|
||||
# Prepare uvicorn config
|
||||
# When using workers or reload, we must use import string so each worker can import the app
|
||||
use_import_string = args.workers > 1 or args.reload
|
||||
# Check for uvloop availability
|
||||
try:
|
||||
import uvloop # noqa: F401
|
||||
# Check for uvloop/winloop availability
|
||||
import sys
|
||||
|
||||
loop_impl = "uvloop"
|
||||
print("uvloop available, will use for event loop")
|
||||
except ImportError:
|
||||
loop_impl = "asyncio"
|
||||
print("uvloop not installed, using default asyncio event loop")
|
||||
loop_impl = "asyncio"
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import winloop
|
||||
|
||||
winloop.install() # Patches asyncio globally — uvicorn uses "asyncio" but gets winloop
|
||||
loop_impl = "asyncio" # Tell uvicorn "asyncio" — it's now winloop underneath
|
||||
print("winloop installed as asyncio event loop policy (Windows uvloop port)")
|
||||
except ImportError:
|
||||
print("winloop not installed, using default asyncio event loop")
|
||||
else:
|
||||
try:
|
||||
import uvloop # noqa: F401
|
||||
|
||||
loop_impl = "uvloop"
|
||||
print("uvloop available, will use for event loop")
|
||||
except ImportError:
|
||||
print("uvloop not installed, using default asyncio event loop")
|
||||
|
||||
uvicorn_config = {
|
||||
"app": "hindsight_api.server:app" if use_import_string else app,
|
||||
|
||||
@@ -42,6 +42,9 @@ class MCPToolsConfig:
|
||||
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
|
||||
api_key_id_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# How to resolve mcp_authenticated flag (set when MCP_AUTH_TOKEN validates)
|
||||
mcp_authenticated_resolver: Callable[[], bool] | None = None
|
||||
|
||||
# Whether to include bank_id as a parameter on tools (for multi-bank support)
|
||||
include_bank_id_param: bool = False
|
||||
|
||||
@@ -64,7 +67,10 @@ def _get_request_context(config: MCPToolsConfig) -> RequestContext:
|
||||
api_key = config.api_key_resolver() if config.api_key_resolver else None
|
||||
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)
|
||||
mcp_authenticated = config.mcp_authenticated_resolver() if config.mcp_authenticated_resolver else False
|
||||
return RequestContext(
|
||||
api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id, mcp_authenticated=mcp_authenticated
|
||||
)
|
||||
|
||||
|
||||
def parse_timestamp(timestamp: str) -> datetime | None:
|
||||
@@ -95,6 +101,7 @@ def build_content_dict(
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Build a content dict for retain operations.
|
||||
|
||||
@@ -105,10 +112,24 @@ def build_content_dict(
|
||||
tags: Optional tags for scoped visibility filtering
|
||||
metadata: Optional key-value metadata to attach to the memory
|
||||
document_id: Optional document ID to associate the memory with
|
||||
strategy: Optional named retain strategy override (e.g., 'exact', 'verbose')
|
||||
|
||||
Returns:
|
||||
Tuple of (content_dict, error_message). error_message is None if successful.
|
||||
"""
|
||||
# Coerce tags from JSON string to list if needed.
|
||||
# MCP tool bridges sometimes serialize JSON arrays as strings during
|
||||
# transport, e.g. '["a", "b"]' instead of ["a", "b"].
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
parsed = json.loads(tags)
|
||||
if isinstance(parsed, list):
|
||||
tags = parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if isinstance(tags, str):
|
||||
tags = [tags]
|
||||
|
||||
content_dict: dict[str, Any] = {"content": content, "context": context}
|
||||
|
||||
if timestamp:
|
||||
@@ -124,6 +145,8 @@ def build_content_dict(
|
||||
content_dict["metadata"] = metadata
|
||||
if document_id is not None:
|
||||
content_dict["document_id"] = document_id
|
||||
if strategy is not None:
|
||||
content_dict["strategy"] = strategy
|
||||
|
||||
return content_dict, None
|
||||
|
||||
@@ -353,6 +376,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
bank_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
@@ -363,12 +387,13 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||
document_id: Optional document ID to associate this memory with
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
"""
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id)
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
@@ -402,6 +427,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
@@ -411,12 +437,13 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123'])
|
||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||
document_id: Optional document ID to associate this memory with
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
"""
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id)
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ This module provides metrics for:
|
||||
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import types
|
||||
|
||||
try:
|
||||
import resource
|
||||
except ImportError:
|
||||
resource: types.ModuleType | None = None # Windows doesn't have resource module
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
@@ -455,6 +460,8 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
|
||||
def _setup_process_metrics(self):
|
||||
"""Set up observable gauges for process metrics."""
|
||||
if resource is None:
|
||||
return # Skip process metrics on Windows
|
||||
|
||||
def get_cpu_times(_options):
|
||||
"""Get process CPU times."""
|
||||
|
||||
@@ -21,6 +21,7 @@ class RequestContext:
|
||||
api_key_id: str | None = None # UUID of the API key used for authentication
|
||||
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
|
||||
internal: bool = False # True for background/internal operations (skips extension auth)
|
||||
mcp_authenticated: bool = False # True when MCP transport auth already validated (skips tenant re-auth)
|
||||
user_initiated: bool = False # True for async operations that originated from a user request
|
||||
allowed_bank_ids: list[str] | None = None # None = unrestricted (all banks)
|
||||
|
||||
|
||||
@@ -302,19 +302,106 @@ class WorkerPoller:
|
||||
)
|
||||
|
||||
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
|
||||
"""Mark a task as failed with error message."""
|
||||
"""Mark a task as failed with error message, then propagate to parent if applicable."""
|
||||
table = fq_table("async_operations", schema)
|
||||
# Truncate error message if too long (max 5000 chars in schema)
|
||||
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
|
||||
await self._pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
error_message,
|
||||
)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
error_message,
|
||||
)
|
||||
await self._maybe_update_parent_operation(operation_id, schema, conn)
|
||||
|
||||
async def _maybe_update_parent_operation(self, child_operation_id: str, schema: str | None, conn) -> None:
|
||||
"""If this operation is a child of a batch_retain, update the parent status when all siblings are done.
|
||||
|
||||
Must be called within an active transaction that has already updated the child's status.
|
||||
The memory engine has an equivalent method that runs inside task execution transactions.
|
||||
This poller-level version handles the case where a task fails via an unhandled exception
|
||||
that bypasses the memory engine's own failure path (e.g. a DB constraint violation that
|
||||
rolls back the engine's transaction before it can update the parent).
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
f"SELECT result_metadata, bank_id FROM {table} WHERE operation_id = $1",
|
||||
uuid.UUID(child_operation_id),
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
|
||||
result_metadata = row["result_metadata"] or {}
|
||||
if isinstance(result_metadata, str):
|
||||
result_metadata = json.loads(result_metadata)
|
||||
parent_operation_id = result_metadata.get("parent_operation_id")
|
||||
if not parent_operation_id:
|
||||
return
|
||||
|
||||
bank_id = row["bank_id"]
|
||||
|
||||
# Lock parent to prevent concurrent sibling updates
|
||||
parent_row = await conn.fetchrow(
|
||||
f"SELECT operation_id FROM {table} WHERE operation_id = $1 AND bank_id = $2 FOR UPDATE",
|
||||
uuid.UUID(parent_operation_id),
|
||||
bank_id,
|
||||
)
|
||||
if not parent_row:
|
||||
return
|
||||
|
||||
# Check whether all siblings are done
|
||||
siblings = await conn.fetch(
|
||||
f"""
|
||||
SELECT status FROM {table}
|
||||
WHERE bank_id = $1
|
||||
AND result_metadata::jsonb @> $2::jsonb
|
||||
""",
|
||||
bank_id,
|
||||
json.dumps({"parent_operation_id": parent_operation_id}),
|
||||
)
|
||||
if not siblings or not all(s["status"] in ("completed", "failed") for s in siblings):
|
||||
return
|
||||
|
||||
any_failed = any(s["status"] == "failed" for s in siblings)
|
||||
if any_failed:
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
"One or more sub-batches failed",
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'completed', updated_at = now(), completed_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
)
|
||||
logger.info(
|
||||
f"Poller updated parent operation {parent_operation_id} to "
|
||||
f"{'failed' if any_failed else 'completed'} (all siblings done)"
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't re-raise — the child has already been marked failed,
|
||||
# which is the critical state change. A stuck parent will be caught on
|
||||
# the next run or via monitoring.
|
||||
logger.error(f"Failed to update parent operation for child {child_operation_id}: {e}")
|
||||
|
||||
async def _schedule_retry(self, operation_id: str, retry_at: "Any", error_message: str, schema: str | None):
|
||||
"""Reset task to pending with a future retry timestamp."""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api-slim"
|
||||
version = "0.4.19"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -40,12 +40,13 @@ dependencies = [
|
||||
"anthropic>=0.40.0",
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
"litellm>=1.0.0",
|
||||
"litellm>=1.0.0,<=1.82.6", # 1.82.7+ contains a supply chain attack (malicious .pth credential stealer)
|
||||
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
|
||||
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
|
||||
"uvloop>=0.22.1",
|
||||
"winloop>=0.1.0; sys_platform == 'win32'",
|
||||
"uvloop>=0.22.1; sys_platform != 'win32'",
|
||||
# Transitive dependency security fixes
|
||||
"pyasn1>=0.6.2", # DoS vulnerability fix
|
||||
"pyasn1>=0.6.3", # DoS vulnerability fix
|
||||
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
|
||||
"langchain-core>=1.2.11", # Serialization injection + SSRF vulnerability fix
|
||||
"langsmith>=0.6.3", # SSRF via tracing header injection fix
|
||||
@@ -53,9 +54,13 @@ dependencies = [
|
||||
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
|
||||
"cryptography>=46.0.5", # Subgroup attack vulnerability fix
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"authlib>=1.6.6", # Account takeover vulnerability fix
|
||||
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
|
||||
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
|
||||
"orjson>=3.11.6", # Unbounded recursion DoS fix
|
||||
"tornado>=6.5.5", # DoS multipart/incomplete cookie validation fix
|
||||
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
|
||||
"claude-agent-sdk>=0.1.27; sys_platform == 'darwin'",
|
||||
"claude-agent-sdk>=0.1.27",
|
||||
"boto3>=1.42.74",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -48,7 +48,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
Session-scoped fixture that ensures pg0 is running, migrations are applied,
|
||||
and returns the database URL.
|
||||
|
||||
If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management).
|
||||
If HINDSIGHT_API_DATABASE_URL is a plain postgresql:// URL, uses it directly.
|
||||
If HINDSIGHT_API_DATABASE_URL is a pg0:// URL, resolves it to a real URL first.
|
||||
Otherwise, starts pg0 once for the entire test session.
|
||||
|
||||
Uses filelock to ensure only one pytest-xdist worker starts pg0.
|
||||
@@ -58,10 +59,23 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate
|
||||
processes that share the same pg0 instance. pg0 will persist for the next test run.
|
||||
"""
|
||||
if db_url:
|
||||
# Use provided database URL directly
|
||||
from hindsight_api.pg0 import parse_pg0_url as _parse_pg0_url
|
||||
|
||||
# Determine pg0 instance name/port from db_url (if it's a pg0:// URL) or use defaults
|
||||
if db_url and not _parse_pg0_url(db_url)[0]:
|
||||
# Plain postgresql:// URL - use it directly but still run migrations
|
||||
from hindsight_api.migrations import run_migrations
|
||||
run_migrations(db_url)
|
||||
return db_url
|
||||
|
||||
if db_url:
|
||||
_, pg0_name, pg0_port = _parse_pg0_url(db_url)
|
||||
pg0_instance_name = pg0_name or DEFAULT_PG0_INSTANCE_NAME
|
||||
pg0_instance_port = pg0_port or DEFAULT_PG0_PORT
|
||||
else:
|
||||
pg0_instance_name = DEFAULT_PG0_INSTANCE_NAME
|
||||
pg0_instance_port = DEFAULT_PG0_PORT
|
||||
|
||||
# Get shared temp dir for coordination between xdist workers
|
||||
if worker_id == "master":
|
||||
# Running without xdist (-n 0 or no -n flag)
|
||||
@@ -71,8 +85,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
||||
|
||||
# Use a lock file to ensure only one worker starts pg0
|
||||
lock_file = root_tmp_dir / "pg0_setup.lock"
|
||||
url_file = root_tmp_dir / "pg0_url.txt"
|
||||
lock_file = root_tmp_dir / f"pg0_setup_{pg0_instance_name}.lock"
|
||||
url_file = root_tmp_dir / f"pg0_url_{pg0_instance_name}.txt"
|
||||
|
||||
with filelock.FileLock(str(lock_file)):
|
||||
if url_file.exists():
|
||||
@@ -80,7 +94,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
url = url_file.read_text().strip()
|
||||
else:
|
||||
# First worker - start pg0
|
||||
pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT)
|
||||
pg0 = EmbeddedPostgres(name=pg0_instance_name, port=pg0_instance_port)
|
||||
|
||||
# Run ensure_running in a new event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Tests for EntityResolver edge cases.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.entity_resolver import EntityResolver
|
||||
from hindsight_api.pg0 import resolve_database_url
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for discard_pending_stats() — no database required
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_pending_stats_clears_both_dicts():
|
||||
"""discard_pending_stats() must remove entries for the current task from
|
||||
both _pending_stats and _pending_cooccurrences."""
|
||||
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
|
||||
key = resolver._task_key()
|
||||
|
||||
resolver._pending_stats[key] = [object()] # type: ignore[list-item]
|
||||
resolver._pending_cooccurrences[key] = [object()] # type: ignore[list-item]
|
||||
|
||||
resolver.discard_pending_stats()
|
||||
|
||||
assert key not in resolver._pending_stats
|
||||
assert key not in resolver._pending_cooccurrences
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_pending_stats_is_idempotent():
|
||||
"""Calling discard_pending_stats() when nothing is pending must not raise."""
|
||||
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
|
||||
resolver.discard_pending_stats()
|
||||
resolver.discard_pending_stats() # second call — still safe
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_pending_stats_does_not_affect_other_task_keys():
|
||||
"""discard_pending_stats() must only remove the current task's entries,
|
||||
leaving entries keyed under other task IDs untouched."""
|
||||
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
|
||||
other_key = -1 # A fake key that can never be a real task id
|
||||
|
||||
resolver._pending_stats[other_key] = [object()] # type: ignore[list-item]
|
||||
resolver._pending_cooccurrences[other_key] = [object()] # type: ignore[list-item]
|
||||
|
||||
resolver.discard_pending_stats() # discards current task's key only
|
||||
|
||||
assert other_key in resolver._pending_stats, "other task's stats must be preserved"
|
||||
assert other_key in resolver._pending_cooccurrences, "other task's cooccurrences must be preserved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_entities_batch_handles_unicode_lower_conflicts(pg0_db_url):
|
||||
"""
|
||||
Existing entities with PostgreSQL/Python lowercase mismatches should resolve
|
||||
to the conflicted row instead of leaving a missing entity_id.
|
||||
"""
|
||||
resolved_url = await resolve_database_url(pg0_db_url)
|
||||
pool = await asyncpg.create_pool(resolved_url, min_size=1, max_size=2, command_timeout=30)
|
||||
bank_id = f"test-entity-resolver-{uuid.uuid4().hex[:8]}"
|
||||
event_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
|
||||
resolver = EntityResolver(pool=pool, entity_lookup="full")
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
existing_entity_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
|
||||
VALUES ($1, $2, $3, $3, 1)
|
||||
RETURNING id
|
||||
""",
|
||||
bank_id,
|
||||
"İstanbul",
|
||||
event_date,
|
||||
)
|
||||
|
||||
resolved_ids = await resolver.resolve_entities_batch(
|
||||
bank_id=bank_id,
|
||||
entities_data=[
|
||||
{
|
||||
"text": "istanbul",
|
||||
"nearby_entities": [],
|
||||
"event_date": event_date,
|
||||
}
|
||||
],
|
||||
context="unicode case mismatch",
|
||||
unit_event_date=event_date,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, canonical_name
|
||||
FROM entities
|
||||
WHERE bank_id = $1
|
||||
ORDER BY canonical_name
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
assert resolved_ids == [existing_entity_id]
|
||||
assert len(entity_rows) == 1
|
||||
assert entity_rows[0]["id"] == existing_entity_id
|
||||
assert entity_rows[0]["canonical_name"] == "İstanbul"
|
||||
finally:
|
||||
await pool.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
|
||||
await pool.close()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Unit tests for EntityResolver pg_trgm auto-detection (PR #626/#649).
|
||||
|
||||
These tests verify:
|
||||
1. When entity_lookup="trigram" and pg_trgm IS available, the trigram path is used.
|
||||
2. When entity_lookup="trigram" and pg_trgm is NOT available, the resolver falls back
|
||||
to entity_lookup="full" and uses the full-scan path.
|
||||
3. The pg_trgm check is only performed once (_pg_trgm_checked flag prevents re-checking).
|
||||
4. When entity_lookup="full" from the start, the trgm check is never performed.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.entity_resolver import EntityResolver
|
||||
|
||||
|
||||
def _make_conn(pg_trgm_available: bool) -> MagicMock:
|
||||
"""Create a minimal mock asyncpg connection for the pg_trgm availability check."""
|
||||
conn = MagicMock()
|
||||
conn.fetchval = AsyncMock(return_value=pg_trgm_available)
|
||||
conn.fetch = AsyncMock(return_value=[])
|
||||
conn.executemany = AsyncMock()
|
||||
conn.fetchrow = AsyncMock(return_value=None)
|
||||
return conn
|
||||
|
||||
|
||||
def _make_resolver(entity_lookup: str = "trigram") -> EntityResolver:
|
||||
"""Return an EntityResolver with a None pool (not needed for unit tests)."""
|
||||
return EntityResolver(pool=None, entity_lookup=entity_lookup) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestPgTrgmAutoDetection:
|
||||
"""Unit tests for pg_trgm detection logic inside _resolve_entities_batch_impl."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_full_when_pg_trgm_unavailable(self):
|
||||
"""When pg_trgm is absent the resolver switches to 'full' and calls the full-scan path."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# Trigram path must NOT be called
|
||||
mock_trgm.assert_not_called()
|
||||
# Full-scan path must be called as the fallback
|
||||
mock_full.assert_called_once()
|
||||
# Strategy is permanently downgraded
|
||||
assert resolver.entity_lookup == "full"
|
||||
assert resolver._pg_trgm_checked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_trigram_when_pg_trgm_available(self):
|
||||
"""When pg_trgm is present the trigram path is used."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=True)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
mock_trgm.assert_called_once()
|
||||
mock_full.assert_not_called()
|
||||
assert resolver.entity_lookup == "trigram"
|
||||
assert resolver._pg_trgm_checked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_trgm_check_performed_only_once(self):
|
||||
"""The fetchval check is only issued on the first call; subsequent calls skip it."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=True)
|
||||
|
||||
with patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])):
|
||||
# First call — check is issued
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
# Second call — check must NOT be issued again
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# fetchval (the pg_trgm availability query) should be called exactly once
|
||||
assert conn.fetchval.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_strategy_skips_pg_trgm_check(self):
|
||||
"""When entity_lookup='full' from the start, no pg_trgm check is ever issued."""
|
||||
resolver = _make_resolver(entity_lookup="full")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# fetchval should never be called when entity_lookup is already "full"
|
||||
conn.fetchval.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_is_sticky_across_calls(self):
|
||||
"""After falling back to 'full', subsequent calls also use the full path."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
# First call triggers the fallback
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="b",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
# Second call — _pg_trgm_checked is True so no re-check; entity_lookup=="full"
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="b",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# Trigram path is never called
|
||||
mock_trgm.assert_not_called()
|
||||
# Full-scan path is called both times
|
||||
assert mock_full.call_count == 2
|
||||
# pg_trgm check was issued exactly once
|
||||
assert conn.fetchval.call_count == 1
|
||||
@@ -5,6 +5,7 @@ End-to-end tests for file retain (upload, convert, retain) functionality.
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -471,6 +472,77 @@ async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_v
|
||||
assert len(doc["original_text"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_file_retain_serializes_datetime_timestamp(memory_no_llm_verify, sample_txt_content):
|
||||
"""Async file retain should accept Python datetimes in task payloads."""
|
||||
from hindsight_api.engine.parsers.base import FileParser
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
bank_id = f"test_file_timestamp_bank_{datetime.now(timezone.utc).timestamp()}"
|
||||
timestamp = datetime(2024, 1, 15, 10, 30, tzinfo=timezone.utc)
|
||||
|
||||
context = RequestContext(internal=True)
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
|
||||
|
||||
class MockFile:
|
||||
def __init__(self, content, filename, content_type):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
|
||||
async def read(self):
|
||||
return self.content
|
||||
|
||||
class TimestampParser(FileParser):
|
||||
async def convert(self, file_data: bytes, filename: str) -> str:
|
||||
return file_data.decode("utf-8")
|
||||
|
||||
def supports(self, filename: str, content_type: str | None = None) -> bool:
|
||||
return filename.endswith(".txt")
|
||||
|
||||
def name(self) -> str:
|
||||
return "timestamp_parser"
|
||||
|
||||
memory_no_llm_verify._parser_registry.register(TimestampParser())
|
||||
|
||||
mock_file = MockFile(sample_txt_content, "timestamped.txt", "text/plain")
|
||||
|
||||
result = await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=[
|
||||
{
|
||||
"file": mock_file,
|
||||
"document_id": "timestamped_doc",
|
||||
"context": "timestamp test",
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": timestamp,
|
||||
"parser": ["timestamp_parser"],
|
||||
}
|
||||
],
|
||||
document_tags=None,
|
||||
request_context=context,
|
||||
)
|
||||
|
||||
operation_id = result["operation_ids"][0]
|
||||
pool = await memory_no_llm_verify._get_pool()
|
||||
from hindsight_api.engine.memory_engine import get_current_schema
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT status, task_payload->>'timestamp' AS timestamp
|
||||
FROM {get_current_schema()}.async_operations
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
)
|
||||
|
||||
assert row is not None
|
||||
assert row["status"] == "completed"
|
||||
assert row["timestamp"] == "2024-01-15T10:30:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verify, sample_txt_content):
|
||||
"""Test that when file conversion fails, the operation status is set to 'failed' not 'completed'."""
|
||||
|
||||
@@ -89,7 +89,7 @@ async def test_hierarchical_fields_categorization():
|
||||
assert "entity_labels" in configurable
|
||||
|
||||
# Verify count is correct
|
||||
assert len(configurable) == 19
|
||||
assert len(configurable) == 20
|
||||
|
||||
# Verify credential fields (NEVER exposed)
|
||||
assert "llm_api_key" in credentials
|
||||
@@ -402,7 +402,7 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory
|
||||
assert field in config, f"Expected configurable field '{field}' missing from config"
|
||||
|
||||
# Should have a small number of configurable fields (not hundreds)
|
||||
assert len(config) < 20, f"Too many fields returned: {len(config)}"
|
||||
assert len(config) < 25, f"Too many fields returned: {len(config)}"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -43,6 +43,8 @@ MODEL_MATRIX = [
|
||||
("claude-code", "claude-sonnet-4-20250514"),
|
||||
# OpenAI Codex (uses MCP with Codex-specific models)
|
||||
("openai-codex", "gpt-5.2-codex"),
|
||||
# Bedrock models (via LiteLLM)
|
||||
("bedrock", "us.amazon.nova-2-lite-v1:0"),
|
||||
# Mock provider (for testing)
|
||||
("mock", "mock"),
|
||||
]
|
||||
@@ -78,6 +80,12 @@ def should_skip_provider(provider: str, model: str = "") -> tuple[bool, str]:
|
||||
if provider == "ollama" and "gemma" in model.lower():
|
||||
return True, f"Ollama {model} does not support tool calling"
|
||||
|
||||
# Bedrock needs AWS credentials
|
||||
if provider == "bedrock":
|
||||
if not os.getenv("AWS_ACCESS_KEY_ID"):
|
||||
return True, "No AWS credentials available (set AWS_ACCESS_KEY_ID)"
|
||||
return False, ""
|
||||
|
||||
# Other providers need an API key
|
||||
if provider not in ("ollama", "claude-code", "openai-codex", "mock"):
|
||||
api_key = get_api_key_for_provider(provider)
|
||||
@@ -227,7 +235,7 @@ async def test_llm_provider_api_methods(provider: str, model: str):
|
||||
|
||||
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.timeout(300)
|
||||
@pytest.mark.timeout(600) # 600s: some providers (e.g., bedrock via litellm) need extra time for fact extraction
|
||||
async def test_llm_provider_memory_operations(provider: str, model: str):
|
||||
"""
|
||||
Test LLM provider with actual memory operations: fact extraction and reflect.
|
||||
|
||||
@@ -4,6 +4,9 @@ This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets,
|
||||
and that URLs with or without trailing slashes both work (no 307 redirect).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp.client.session import ClientSession
|
||||
@@ -278,3 +281,93 @@ async def test_mcp_bank_named_messages_routes_to_single_bank(memory):
|
||||
|
||||
assert "retain" in tools
|
||||
assert "list_banks" not in tools, "Bank 'messages' should route to single-bank mode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_execution_with_different_mcp_and_tenant_tokens(memory):
|
||||
"""Test that MCP tool calls work when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
|
||||
|
||||
Regression test for https://github.com/vectorize-io/hindsight/issues/627
|
||||
When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are configured
|
||||
with different values, tool calls should succeed because MCP transport auth
|
||||
already validated the token — the tenant extension should not re-validate.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
|
||||
# Configure ApiKeyTenantExtension with a different key than the MCP token
|
||||
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
|
||||
memory._tenant_extension = tenant_ext
|
||||
|
||||
# Patch MCP_AUTH_TOKEN so the MCP middleware uses legacy auth
|
||||
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
# Pass auth header via the httpx client (streamable_http_client doesn't accept headers)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {mcp_token}"},
|
||||
) as http_client:
|
||||
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()
|
||||
|
||||
# list_tools should work
|
||||
tools_result = await session.list_tools()
|
||||
tool_names = {t.name for t in tools_result.tools}
|
||||
assert "get_bank" in tool_names
|
||||
|
||||
# Tool execution should work (this was failing before the fix)
|
||||
result = await session.call_tool("list_banks", arguments={})
|
||||
assert result is not None
|
||||
assert len(result.content) > 0
|
||||
parsed = json.loads(result.content[0].text)
|
||||
assert "banks" in parsed
|
||||
assert "error" not in parsed, f"Tool call failed with: {parsed.get('error')}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_rejects_wrong_mcp_token_even_if_matches_tenant_key(memory):
|
||||
"""Test that an invalid MCP token is rejected even if it matches the tenant key.
|
||||
|
||||
When MCP_AUTH_TOKEN is set, the MCP middleware should validate against that token,
|
||||
not the tenant API key.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
|
||||
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
|
||||
memory._tenant_extension = tenant_ext
|
||||
|
||||
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
# Try connecting with the tenant key (wrong for MCP auth)
|
||||
response = await http_client.post(
|
||||
"http://test/mcp/",
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Tests for the 'none' LLM provider mode.
|
||||
|
||||
Verifies that when HINDSIGHT_API_LLM_PROVIDER=none:
|
||||
- Retain defaults to chunks mode (no LLM calls)
|
||||
- Reflect returns 400
|
||||
- Mental model refresh returns 400
|
||||
- Consolidation is skipped
|
||||
- NoneLLM.call() raises LLMNotAvailableError
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api import LLMConfig, LocalSTEmbeddings, MemoryEngine, RequestContext
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError, NoneLLM
|
||||
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
|
||||
from hindsight_api.engine.task_backend import SyncTaskBackend
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def request_context():
|
||||
return RequestContext()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def none_memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
|
||||
"""MemoryEngine with provider=none."""
|
||||
mem = MemoryEngine(
|
||||
db_url=pg0_db_url,
|
||||
memory_llm_provider="none",
|
||||
memory_llm_api_key=None,
|
||||
memory_llm_model="none",
|
||||
embeddings=embeddings,
|
||||
cross_encoder=cross_encoder,
|
||||
query_analyzer=query_analyzer,
|
||||
pool_min_size=1,
|
||||
pool_max_size=5,
|
||||
run_migrations=False,
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
await mem.initialize()
|
||||
yield mem
|
||||
try:
|
||||
if mem._pool and not mem._pool._closing:
|
||||
await mem.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def none_api_client(none_memory):
|
||||
"""HTTP test client backed by a none-provider MemoryEngine."""
|
||||
app = create_app(none_memory, initialize_memory=False)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
|
||||
# -- Unit tests for NoneLLM ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_llm_call_raises():
|
||||
"""NoneLLM.call() should raise LLMNotAvailableError."""
|
||||
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await llm.call(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_llm_call_with_tools_raises():
|
||||
"""NoneLLM.call_with_tools() should raise LLMNotAvailableError."""
|
||||
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
tools=[{"type": "function", "function": {"name": "test", "parameters": {}}}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_llm_verify_connection_succeeds():
|
||||
"""NoneLLM.verify_connection() should be a no-op."""
|
||||
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
|
||||
await llm.verify_connection() # Should not raise
|
||||
|
||||
|
||||
# -- Config validation tests ---------------------------------------------------
|
||||
|
||||
|
||||
def test_config_forces_chunks_mode():
|
||||
"""When provider is 'none', config.validate() forces retain_extraction_mode='chunks'."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
config = HindsightConfig.from_env()
|
||||
# Override to none for test
|
||||
config.llm_provider = "none"
|
||||
config.retain_extraction_mode = "facts"
|
||||
config.enable_observations = True
|
||||
config.validate()
|
||||
|
||||
assert config.retain_extraction_mode == "chunks"
|
||||
assert config.enable_observations is False
|
||||
|
||||
|
||||
# -- Integration tests (require database) -------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retain_works_with_none_provider(none_memory, request_context):
|
||||
"""Retain should work with provider=none, storing chunks without LLM calls."""
|
||||
bank_id = f"test_none_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
unit_ids = await none_memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice is a software engineer. She works at TechCorp and loves Python.",
|
||||
context="team info",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(unit_ids) > 0, "Should store chunks even without an LLM"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_works_with_none_provider(none_memory, request_context):
|
||||
"""Recall should work with provider=none (uses embeddings, not LLM)."""
|
||||
bank_id = f"test_none_recall_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
await none_memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice is a software engineer at TechCorp.",
|
||||
context="team info",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result = await none_memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Who is Alice?",
|
||||
budget=Budget.LOW,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should find results via semantic search"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_raises_with_none_provider(none_memory, request_context):
|
||||
"""Reflect should raise LLMNotAvailableError with provider=none."""
|
||||
bank_id = f"test_none_reflect_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await none_memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What do you know?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_skipped_with_none_provider(none_memory, request_context):
|
||||
"""Consolidation handler should skip when provider=none."""
|
||||
result = await none_memory._handle_consolidation({"bank_id": "test_bank"})
|
||||
assert result["skipped"] is True
|
||||
assert result["memories_processed"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mental_model_refresh_raises_with_none_provider(none_memory, request_context):
|
||||
"""Mental model refresh should raise LLMNotAvailableError with provider=none."""
|
||||
bank_id = f"test_none_mm_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await none_memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id="fake-id",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
|
||||
# -- HTTP API tests -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_reflect_returns_400(none_api_client):
|
||||
"""Reflect endpoint should return 400 when LLM provider is none."""
|
||||
bank_id = f"test_none_http_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
response = await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/reflect",
|
||||
json={"query": "What do you know?"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "none" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_retain_works(none_api_client):
|
||||
"""Retain endpoint should work with provider=none (chunks mode)."""
|
||||
bank_id = f"test_none_http_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
response = await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={"items": [{"content": "Hello world", "context": "test"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_recall_works(none_api_client):
|
||||
"""Recall endpoint should work with provider=none."""
|
||||
bank_id = f"test_none_http_recall_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
# Retain first
|
||||
await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={"items": [{"content": "Alice is an engineer.", "context": "test"}]},
|
||||
)
|
||||
|
||||
# Recall
|
||||
response = await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={"query": "Alice"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -97,8 +97,8 @@ def test_per_operation_provider_default_model():
|
||||
|
||||
# Retain should use Anthropic default
|
||||
assert (
|
||||
config.retain_llm_model == "claude-haiku-4-5-20251001"
|
||||
), f"Expected claude-haiku-4-5-20251001, got {config.retain_llm_model}"
|
||||
config.retain_llm_model == "claude-haiku-4-5"
|
||||
), f"Expected claude-haiku-4-5, got {config.retain_llm_model}"
|
||||
|
||||
finally:
|
||||
clear_config_cache()
|
||||
|
||||
@@ -5,8 +5,10 @@ These tests verify:
|
||||
1. Tool name normalization for various LLM output formats
|
||||
2. Recovery from unknown tool calls
|
||||
3. Recovery from tool execution errors
|
||||
4. Wall-clock timeout enforcement
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -416,6 +418,32 @@ class TestReflectAgentMocked:
|
||||
assert result is not None
|
||||
assert result.iterations == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wall_clock_timeout(self, mock_llm: MagicMock, mock_functions: dict[str, AsyncMock]) -> None:
|
||||
"""Test that asyncio.wait_for can enforce a wall-clock timeout on run_reflect_agent."""
|
||||
|
||||
async def slow_llm_call(*args: object, **kwargs: object) -> LLMToolCallResult:
|
||||
await asyncio.sleep(10) # Simulate a slow LLM call
|
||||
return LLMToolCallResult(
|
||||
tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
mock_llm.call_with_tools.side_effect = slow_llm_call
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
run_reflect_agent(
|
||||
llm_config=mock_llm,
|
||||
bank_id="test-bank",
|
||||
query="test query",
|
||||
bank_profile={"name": "Test", "mission": "Testing"},
|
||||
max_iterations=5,
|
||||
**mock_functions,
|
||||
),
|
||||
timeout=0.1, # Very short timeout to trigger quickly
|
||||
)
|
||||
|
||||
|
||||
class TestContextOverflowHelpers:
|
||||
"""Unit tests for context-overflow detection helpers."""
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for reflect search_observations source_facts_max_tokens configuration.
|
||||
|
||||
Verifies that the source_facts_max_tokens parameter correctly controls
|
||||
whether source facts are included in search_observations recall calls.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.reflect.tools import tool_search_observations
|
||||
from hindsight_api.engine.response_models import RecallResult
|
||||
|
||||
|
||||
def _make_mock_engine(recall_result=None):
|
||||
"""Create a mock memory engine with a recall_async method."""
|
||||
if recall_result is None:
|
||||
recall_result = RecallResult(results=[], source_facts={})
|
||||
engine = MagicMock()
|
||||
engine.recall_async = AsyncMock(return_value=recall_result)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request_context():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class TestSearchObservationsSourceFacts:
|
||||
"""Test source_facts_max_tokens parameter in tool_search_observations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_disables_source_facts(self, mock_request_context):
|
||||
"""Default source_facts_max_tokens=-1 should disable source facts."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is False
|
||||
assert "max_source_facts_tokens" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_enables_source_facts_unlimited(self, mock_request_context):
|
||||
"""source_facts_max_tokens=0 should enable source facts with no token limit."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context,
|
||||
source_facts_max_tokens=0,
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is True
|
||||
assert "max_source_facts_tokens" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_enables_source_facts_with_limit(self, mock_request_context):
|
||||
"""source_facts_max_tokens>0 should enable source facts with a token budget."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context,
|
||||
source_facts_max_tokens=5000,
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is True
|
||||
assert call_kwargs["max_source_facts_tokens"] == 5000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_one_disables_source_facts(self, mock_request_context):
|
||||
"""Explicit -1 should disable source facts (same as default)."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context,
|
||||
source_facts_max_tokens=-1,
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is False
|
||||
assert "max_source_facts_tokens" not in call_kwargs
|
||||
|
||||
|
||||
class TestReflectSourceFactsConfig:
|
||||
"""Test that reflect_source_facts_max_tokens is properly wired in HindsightConfig."""
|
||||
|
||||
def test_config_field_exists(self):
|
||||
"""reflect_source_facts_max_tokens should be a valid config field."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
import dataclasses
|
||||
|
||||
field_names = {f.name for f in dataclasses.fields(HindsightConfig)}
|
||||
assert "reflect_source_facts_max_tokens" in field_names
|
||||
|
||||
def test_config_is_configurable(self):
|
||||
"""reflect_source_facts_max_tokens should be a configurable (per-bank) field."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
assert "reflect_source_facts_max_tokens" in HindsightConfig.get_configurable_fields()
|
||||
|
||||
def test_default_value_is_disabled(self):
|
||||
"""Default should be -1 (disabled)."""
|
||||
from hindsight_api.config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
|
||||
|
||||
assert DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS == -1
|
||||
|
||||
def test_env_var_constant_exists(self):
|
||||
"""Env var constant should be defined."""
|
||||
from hindsight_api.config import ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS
|
||||
|
||||
assert ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS == "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
|
||||
|
||||
@patch.dict("os.environ", {"HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS": "8000"})
|
||||
def test_from_env_reads_value(self):
|
||||
"""from_env should parse the env var."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
config = HindsightConfig.from_env()
|
||||
assert config.reflect_source_facts_max_tokens == 8000
|
||||
@@ -485,3 +485,206 @@ class TestReflectUsesMentalModels:
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestMentalModelReflectOptions:
|
||||
"""Tests for fact_types and exclude_mental_models options stored in the trigger field."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_fact_types(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists fact_types and returns them via get_mental_model."""
|
||||
bank_id = f"test-mm-trigger-ft-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Observations only",
|
||||
source_query="Summarize observations",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "fact_types": ["observation"]},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["fact_types"] == ["observation"]
|
||||
assert fetched["trigger"]["refresh_after_consolidation"] is False
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_exclude_mental_models(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists exclude_mental_models flag."""
|
||||
bank_id = f"test-mm-trigger-em-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="No mental models",
|
||||
source_query="Summarize raw facts",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "exclude_mental_models": True},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["exclude_mental_models"] is True
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_exclude_mental_model_ids(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists exclude_mental_model_ids list."""
|
||||
bank_id = f"test-mm-trigger-eid-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
excluded_ids = ["mm-abc", "mm-xyz"]
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Exclude some models",
|
||||
source_query="Summarize",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "exclude_mental_model_ids": excluded_ids},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["exclude_mental_model_ids"] == excluded_ids
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_reflect_options(self, memory: MemoryEngine, request_context):
|
||||
"""update_mental_model persists updated trigger reflect options."""
|
||||
bank_id = f"test-mm-trigger-upd-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Initially no filter",
|
||||
source_query="Summarize",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
updated = await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm["id"],
|
||||
trigger={
|
||||
"refresh_after_consolidation": True,
|
||||
"fact_types": ["world", "experience"],
|
||||
"exclude_mental_models": False,
|
||||
"exclude_mental_model_ids": ["mm-skip"],
|
||||
},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert updated["trigger"]["refresh_after_consolidation"] is True
|
||||
assert updated["trigger"]["fact_types"] == ["world", "experience"]
|
||||
assert updated["trigger"]["exclude_mental_models"] is False
|
||||
assert updated["trigger"]["exclude_mental_model_ids"] == ["mm-skip"]
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestReflectFactTypeFiltering:
|
||||
"""Tests for fact_types and exclude_mental_models filtering in reflect_async."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_mental_models_skips_search_mental_models_tool(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""When exclude_mental_models=True, search_mental_models is never called."""
|
||||
bank_id = f"test-reflect-exmm-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create a mental model so the bank has one
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Existing Model",
|
||||
source_query="Q",
|
||||
content="Some content about the team",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me about the team",
|
||||
request_context=request_context,
|
||||
exclude_mental_models=True,
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "search_mental_models" not in tool_names, (
|
||||
f"search_mental_models should be excluded but found in: {tool_names}"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_observations_via_fact_types(self, memory: MemoryEngine, request_context):
|
||||
"""When fact_types excludes observation, search_observations is never called."""
|
||||
bank_id = f"test-reflect-exobs-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me something",
|
||||
request_context=request_context,
|
||||
fact_types=["world", "experience"],
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "search_observations" not in tool_names, (
|
||||
f"search_observations should be excluded but found in: {tool_names}"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_only_fact_types_skips_recall(self, memory: MemoryEngine, request_context):
|
||||
"""When fact_types=['observation'], recall is never called."""
|
||||
bank_id = f"test-reflect-obsonly-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me something",
|
||||
request_context=request_context,
|
||||
fact_types=["observation"],
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "recall" not in tool_names, f"recall should be excluded but found in: {tool_names}"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestReflectRequestValidation:
|
||||
"""Tests for ReflectRequest and MentalModelTrigger validation via the HTTP API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_empty_fact_types_rejected(self, api_client, test_bank_id):
|
||||
"""Passing fact_types=[] to reflect must return 422."""
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/reflect",
|
||||
json={"query": "test", "fact_types": []},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mental_model_empty_fact_types_rejected(self, api_client, test_bank_id):
|
||||
"""Passing fact_types=[] inside trigger must return 422."""
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/mental-models",
|
||||
json={
|
||||
"name": "Test",
|
||||
"source_query": "Q",
|
||||
"trigger": {"refresh_after_consolidation": False, "fact_types": []},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -814,34 +814,36 @@ async def test_context_with_batch(memory, request_context):
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_storage_and_retrieval(memory, request_context):
|
||||
"""
|
||||
Test that user-defined metadata is preserved.
|
||||
Test that user-defined metadata passed during retain is returned on recall.
|
||||
Metadata allows arbitrary key-value data to be stored with facts.
|
||||
"""
|
||||
bank_id = f"test_metadata_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Store content with custom metadata
|
||||
custom_metadata = {
|
||||
"source": "slack",
|
||||
"channel": "engineering",
|
||||
"importance": "high",
|
||||
"tags": "product,launch"
|
||||
}
|
||||
|
||||
# Note: retain_async doesn't directly support metadata parameter
|
||||
# Metadata would need to be supported in the API layer
|
||||
# For now, we test that the system handles content without errors
|
||||
unit_ids = await memory.retain_async(
|
||||
# Use retain_batch_async which supports the metadata parameter
|
||||
unit_ids_list = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
content="The product launch is scheduled for March 1st.",
|
||||
context="planning meeting",
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
contents=[
|
||||
{
|
||||
"content": "The product launch is scheduled for March 1st.",
|
||||
"context": "planning meeting",
|
||||
"event_date": datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
"metadata": custom_metadata,
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(unit_ids) > 0, "Should create memory units"
|
||||
assert len(unit_ids_list) > 0, "Should create memory units"
|
||||
assert len(unit_ids_list[0]) > 0, "Should have at least one unit ID"
|
||||
|
||||
# Recall to verify storage worked
|
||||
# Recall and verify metadata is returned
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="When is the product launch?",
|
||||
@@ -853,8 +855,12 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
|
||||
|
||||
assert len(result.results) > 0, "Should recall stored facts"
|
||||
|
||||
print("✓ Successfully stored and retrieved facts")
|
||||
print(" (Note: Metadata support depends on API implementation)")
|
||||
# Verify metadata is present on recalled facts
|
||||
fact = result.results[0]
|
||||
assert fact.metadata is not None, "Metadata should not be null on recall"
|
||||
assert fact.metadata.get("source") == "slack"
|
||||
assert fact.metadata.get("channel") == "engineering"
|
||||
assert fact.metadata.get("importance") == "high"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
|
||||
|
||||
|
||||
class TestStripCodeFences:
|
||||
"""Test markdown code fence stripping from LLM responses."""
|
||||
|
||||
def test_bare_json_unchanged(self):
|
||||
"""Bare JSON passes through unchanged."""
|
||||
content = '{"facts": [{"what": "test"}]}'
|
||||
assert _strip_code_fences(content) == content
|
||||
|
||||
def test_json_fence_stripped(self):
|
||||
"""```json ... ``` fences are stripped."""
|
||||
content = '```json\n{"facts": [{"what": "test"}]}\n```'
|
||||
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
|
||||
|
||||
def test_plain_fence_stripped(self):
|
||||
"""``` ... ``` fences without language tag are stripped."""
|
||||
content = '```\n{"facts": [{"what": "test"}]}\n```'
|
||||
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
|
||||
|
||||
def test_fence_with_trailing_whitespace(self):
|
||||
"""Fences with extra whitespace are handled."""
|
||||
content = '```json\n{"facts": []}\n```\n'
|
||||
result = _strip_code_fences(content)
|
||||
assert result == '{"facts": []}'
|
||||
|
||||
def test_fence_with_leading_whitespace(self):
|
||||
"""Content with leading whitespace before fence."""
|
||||
content = ' ```json\n{"facts": []}\n```'
|
||||
# The function checks for ``` in content, not startswith
|
||||
result = _strip_code_fences(content)
|
||||
assert '{"facts": []}' in result
|
||||
|
||||
def test_no_fences_no_change(self):
|
||||
"""Content without any backticks passes through."""
|
||||
content = "Just some text without fences"
|
||||
assert _strip_code_fences(content) == content
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty string passes through."""
|
||||
assert _strip_code_fences("") == ""
|
||||
|
||||
def test_multiline_json(self):
|
||||
"""Multi-line JSON inside fences is preserved."""
|
||||
content = '```json\n{\n "facts": [\n {"what": "line1"},\n {"what": "line2"}\n ]\n}\n```'
|
||||
result = _strip_code_fences(content)
|
||||
assert '"line1"' in result
|
||||
assert '"line2"' in result
|
||||
assert "```" not in result
|
||||
|
||||
def test_malformed_fence_returns_original(self):
|
||||
"""Malformed fences (missing closing) return something parseable."""
|
||||
content = '```json\n{"facts": []}'
|
||||
result = _strip_code_fences(content)
|
||||
# Should attempt to strip and return best effort
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_minimax_style_response(self):
|
||||
"""Real-world MiniMax response format."""
|
||||
content = (
|
||||
"```json\n"
|
||||
"{\n"
|
||||
' "facts": [\n'
|
||||
" {\n"
|
||||
' "what": "Sebastian switched the Hindsight extraction LLM",\n'
|
||||
' "when": "2026-03-21",\n'
|
||||
' "where": "N/A",\n'
|
||||
' "who": "Sebastian",\n'
|
||||
' "why": "MiniMax wraps JSON in code fences",\n'
|
||||
' "fact_kind": "event",\n'
|
||||
' "fact_type": "world",\n'
|
||||
' "entities": [{"text": "Sebastian"}, {"text": "Hindsight"}],\n'
|
||||
' "labels": {"source_type": "stated", "domain": ["infrastructure"]}\n'
|
||||
" }\n"
|
||||
" ]\n"
|
||||
"}\n"
|
||||
"```"
|
||||
)
|
||||
result = _strip_code_fences(content)
|
||||
assert not result.startswith("```")
|
||||
assert not result.endswith("```")
|
||||
# Should be valid JSON
|
||||
import json
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert len(parsed["facts"]) == 1
|
||||
assert parsed["facts"][0]["who"] == "Sebastian"
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Unit tests for ValidationResult.accept_with() enrichment (PR #639).
|
||||
|
||||
These tests verify:
|
||||
1. The accept_with() factory creates an accepted result with the correct enrichment fields.
|
||||
2. The engine applies enrichment to retain contents and recall tags/tag_groups.
|
||||
3. RecallContext carries tags/tags_match/tag_groups so validators can read filter state.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.extensions import (
|
||||
OperationValidatorExtension,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RetainContext,
|
||||
ValidationResult,
|
||||
)
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure unit tests for ValidationResult factory methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidationResultAcceptWith:
|
||||
"""Unit tests for the accept_with() factory — no DB needed."""
|
||||
|
||||
def test_accept_is_allowed_with_no_enrichment(self):
|
||||
result = ValidationResult.accept()
|
||||
assert result.allowed is True
|
||||
assert result.contents is None
|
||||
assert result.tags is None
|
||||
assert result.tags_match is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_contents(self):
|
||||
contents = [{"content": "enriched text", "tags": ["injected"]}]
|
||||
result = ValidationResult.accept_with(contents=contents)
|
||||
assert result.allowed is True
|
||||
assert result.contents == contents
|
||||
assert result.tags is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_tags(self):
|
||||
result = ValidationResult.accept_with(tags=["alpha", "beta"])
|
||||
assert result.allowed is True
|
||||
assert result.tags == ["alpha", "beta"]
|
||||
assert result.contents is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_tags_match(self):
|
||||
result = ValidationResult.accept_with(tags=["x"], tags_match="all")
|
||||
assert result.allowed is True
|
||||
assert result.tags_match == "all"
|
||||
|
||||
def test_accept_with_tag_groups(self):
|
||||
tag_groups = [{"tags": ["env:prod"], "match": "all"}]
|
||||
result = ValidationResult.accept_with(tag_groups=tag_groups)
|
||||
assert result.allowed is True
|
||||
assert result.tag_groups == tag_groups
|
||||
|
||||
def test_accept_with_all_fields(self):
|
||||
contents = [{"content": "c"}]
|
||||
tags = ["t1"]
|
||||
tag_groups = [{"tags": ["g1"]}]
|
||||
result = ValidationResult.accept_with(
|
||||
contents=contents,
|
||||
tags=tags,
|
||||
tags_match="any",
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
assert result.allowed is True
|
||||
assert result.contents == contents
|
||||
assert result.tags == tags
|
||||
assert result.tags_match == "any"
|
||||
assert result.tag_groups == tag_groups
|
||||
|
||||
def test_reject_ignores_enrichment_fields(self):
|
||||
"""reject() always sets allowed=False and leaves enrichment fields at their defaults."""
|
||||
result = ValidationResult.reject("not allowed", status_code=403)
|
||||
assert result.allowed is False
|
||||
assert result.reason == "not allowed"
|
||||
assert result.status_code == 403
|
||||
assert result.contents is None
|
||||
assert result.tags is None
|
||||
|
||||
def test_none_fields_mean_no_modification(self):
|
||||
"""None enrichment fields must not overwrite engine defaults."""
|
||||
result = ValidationResult.accept_with(tags=None, tag_groups=None)
|
||||
assert result.tags is None
|
||||
assert result.tag_groups is None
|
||||
# Engine should interpret None as "keep original" — we verify the contract here.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: engine applies enrichment from validator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ContentEnrichingValidator(OperationValidatorExtension):
|
||||
"""Validator that injects a tag into every retain content item."""
|
||||
|
||||
def __init__(self, injected_tag: str):
|
||||
super().__init__({})
|
||||
self.injected_tag = injected_tag
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
enriched = []
|
||||
for item in ctx.contents:
|
||||
new_item = dict(item)
|
||||
new_item.setdefault("tags", [])
|
||||
new_item["tags"] = list(new_item["tags"]) + [self.injected_tag]
|
||||
enriched.append(new_item)
|
||||
return ValidationResult.accept_with(contents=enriched)
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
class _TagEnrichingValidator(OperationValidatorExtension):
|
||||
"""Validator that injects tags into every recall operation."""
|
||||
|
||||
def __init__(self, forced_tags: list[str]):
|
||||
super().__init__({})
|
||||
self.forced_tags = forced_tags
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept_with(tags=self.forced_tags, tags_match="all")
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
class _RecallContextCapturingValidator(OperationValidatorExtension):
|
||||
"""Validator that captures the RecallContext for inspection."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__({})
|
||||
self.captured: list[RecallContext] = []
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
self.captured.append(ctx)
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_content_enricher(memory):
|
||||
validator = _ContentEnrichingValidator(injected_tag="validator-injected")
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_tag_enricher(memory):
|
||||
validator = _TagEnrichingValidator(forced_tags=["forced-tag"])
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_recall_context_capture(memory):
|
||||
validator = _RecallContextCapturingValidator()
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
class TestRetainContentEnrichment:
|
||||
"""Engine applies enriched contents returned by validate_retain."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enriched_contents_are_used_for_retain(self, memory_with_content_enricher):
|
||||
"""When validator returns accept_with(contents=...), engine uses those contents."""
|
||||
memory, validator = memory_with_content_enricher
|
||||
bank_id = "test-retain-enrichment"
|
||||
ctx = RequestContext()
|
||||
|
||||
# Retain without any tags — validator should inject "validator-injected"
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "Alice is an engineer."}],
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# Retrieve facts tagged with the injected tag to confirm enrichment was applied
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice",
|
||||
tags=["validator-injected"],
|
||||
request_context=ctx,
|
||||
)
|
||||
# The fact should be retrievable via the injected tag
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestRecallTagEnrichment:
|
||||
"""Engine applies enriched tags returned by validate_recall."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enriched_tags_filter_recall_results(self, memory_with_tag_enricher):
|
||||
"""When validator returns accept_with(tags=...), engine filters recall by those tags."""
|
||||
memory, validator = memory_with_tag_enricher
|
||||
bank_id = "test-recall-tag-enrichment"
|
||||
ctx = RequestContext()
|
||||
|
||||
# Retain one fact with the forced tag and one without
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "Bob is a designer.", "tags": ["forced-tag"]}],
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# recall is called without tags but validator injects "forced-tag" + match=all
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Bob",
|
||||
request_context=ctx,
|
||||
)
|
||||
# Should still get a result — the injected tag matches the stored fact
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestRecallContextContainsTagFields:
|
||||
"""RecallContext passed to validate_recall carries tag filter state."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_context_carries_tags(self, memory_with_recall_context_capture):
|
||||
"""tags, tags_match, and tag_groups are present in RecallContext."""
|
||||
memory, validator = memory_with_recall_context_capture
|
||||
bank_id = "test-recall-ctx-tags"
|
||||
ctx = RequestContext()
|
||||
|
||||
await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="test",
|
||||
tags=["env:prod"],
|
||||
tags_match="all",
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
assert len(validator.captured) == 1
|
||||
rc = validator.captured[0]
|
||||
assert rc.tags == ["env:prod"]
|
||||
assert rc.tags_match == "all"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_context_tags_default_to_none(self, memory_with_recall_context_capture):
|
||||
"""When caller provides no tags, RecallContext.tags is None."""
|
||||
memory, validator = memory_with_recall_context_capture
|
||||
bank_id = "test-recall-ctx-no-tags"
|
||||
ctx = RequestContext()
|
||||
|
||||
await memory.recall_async(bank_id=bank_id, query="test", request_context=ctx)
|
||||
|
||||
assert len(validator.captured) == 1
|
||||
rc = validator.captured[0]
|
||||
assert rc.tags is None
|
||||
@@ -58,10 +58,14 @@ async def pool(pg0_db_url):
|
||||
async def clean_operations(pool):
|
||||
"""Clean up async_operations table before and after tests."""
|
||||
# Clean before test - covers both 'test-worker-' and 'test_worker_recovery' patterns
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
|
||||
await pool.execute(
|
||||
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
|
||||
)
|
||||
yield
|
||||
# Clean after test
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
|
||||
await pool.execute(
|
||||
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
|
||||
)
|
||||
|
||||
|
||||
class TestBrokerTaskBackend:
|
||||
@@ -387,9 +391,7 @@ class TestWorkerPoller:
|
||||
"SELECT status, error_message, retry_count FROM async_operations WHERE operation_id = $1",
|
||||
op_id,
|
||||
)
|
||||
assert row["status"] == "failed", (
|
||||
f"Expected 'failed' for plain exception, got '{row['status']}'"
|
||||
)
|
||||
assert row["status"] == "failed", f"Expected 'failed' for plain exception, got '{row['status']}'"
|
||||
assert row["error_message"] is not None
|
||||
assert row["retry_count"] == 0 # not incremented; plain exception = immediate fail
|
||||
|
||||
@@ -1265,7 +1267,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1294,7 +1298,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1364,7 +1370,9 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
await _ensure_bank(pool, bank_id)
|
||||
for i in range(10):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1396,7 +1404,7 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
completed = 0
|
||||
while completed < 10 and len(tasks_started) < 10:
|
||||
# Release the next batch
|
||||
events_to_release = list(task_events.values())[completed:completed+3]
|
||||
events_to_release = list(task_events.values())[completed : completed + 3]
|
||||
for event in events_to_release:
|
||||
event.set()
|
||||
completed += len(events_to_release)
|
||||
@@ -1417,3 +1425,223 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
await asyncio.wait_for(poll_task, timeout=1.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
class TestMarkFailedParentPropagation:
|
||||
"""Tests for _mark_failed parent propagation in WorkerPoller.
|
||||
|
||||
When a child retain operation fails via an unhandled exception, the memory
|
||||
engine's transaction is rolled back entirely — including any call to
|
||||
_maybe_update_parent_operation inside the engine. The poller's fallback
|
||||
_mark_failed must detect this and finalise the parent batch_retain itself.
|
||||
"""
|
||||
|
||||
async def _insert_op(
|
||||
self,
|
||||
pool,
|
||||
*,
|
||||
op_id: "uuid.UUID",
|
||||
bank_id: str,
|
||||
operation_type: str,
|
||||
status: str,
|
||||
result_metadata: dict | None = None,
|
||||
) -> None:
|
||||
meta_json = json.dumps(result_metadata if result_metadata is not None else {})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations
|
||||
(operation_id, bank_id, operation_type, status, result_metadata)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
status,
|
||||
meta_json,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_finalises_parent_when_last_sibling_fails(self, pool, clean_operations):
|
||||
"""When the last pending child fails, parent batch_retain is marked failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child1_id = uuid.uuid4()
|
||||
child2_id = uuid.uuid4()
|
||||
|
||||
# Parent batch_retain still pending
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
|
||||
# child1 already completed
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child1_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="completed",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
# child2 still processing — this is the one that will fail
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child2_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child2_id), "DB constraint violation", schema=None)
|
||||
|
||||
# child2 must be failed
|
||||
child2_row = await pool.fetchrow(
|
||||
"SELECT status, error_message FROM async_operations WHERE operation_id = $1", child2_id
|
||||
)
|
||||
assert child2_row["status"] == "failed"
|
||||
assert "DB constraint violation" in child2_row["error_message"]
|
||||
|
||||
# parent must now be failed (all siblings done, at least one failed)
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed", (
|
||||
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, clean_operations):
|
||||
"""When the only child fails, parent batch_retain becomes failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child_id), "unexpected error", schema=None)
|
||||
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_does_not_finalise_parent_when_siblings_still_pending(self, pool, clean_operations):
|
||||
"""Parent is NOT updated while other siblings are still processing/pending."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child1_id = uuid.uuid4()
|
||||
child2_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
|
||||
# child1 is the one failing
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child1_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
# child2 is still pending — not done yet
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child2_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="pending",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child1_id), "early failure", schema=None)
|
||||
|
||||
# child1 is failed
|
||||
child1_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child1_id)
|
||||
assert child1_row["status"] == "failed"
|
||||
|
||||
# parent must still be pending (child2 not done)
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "pending", (
|
||||
f"Parent should remain 'pending' while siblings are outstanding, got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_no_parent_is_safe(self, pool, clean_operations):
|
||||
"""Operations without a parent (no result_metadata parent_operation_id) fail cleanly."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
op_id = uuid.uuid4()
|
||||
await self._insert_op(pool, op_id=op_id, bank_id=bank_id, operation_type="retain", status="processing")
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
# Must not raise
|
||||
await poller._mark_failed(str(op_id), "standalone failure", schema=None)
|
||||
|
||||
row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", op_id)
|
||||
assert row["status"] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unhandled_exception_via_execute_task_propagates_to_parent(self, pool, clean_operations):
|
||||
"""End-to-end: executor raises a plain exception, poller calls _mark_failed,
|
||||
which then resolves the parent batch_retain to failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
from hindsight_api.worker.poller import ClaimedTask
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
async def crashing_executor(task_dict):
|
||||
raise RuntimeError("Simulated DB constraint violation — transaction rolled back")
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=crashing_executor)
|
||||
|
||||
task_dict = {"type": "retain", "operation_id": str(child_id), "bank_id": bank_id}
|
||||
claimed_task = ClaimedTask(operation_id=str(child_id), task_dict=task_dict, schema=None)
|
||||
await poller.execute_task(claimed_task)
|
||||
|
||||
completed = await poller.wait_for_active_tasks(timeout=5.0)
|
||||
assert completed, "Task did not complete within timeout"
|
||||
|
||||
child_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child_id)
|
||||
assert child_row["status"] == "failed"
|
||||
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed", (
|
||||
f"Parent batch_retain should be 'failed' after child fails via unhandled exception, "
|
||||
f"got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.19"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -15,5 +15,11 @@ dependencies = [
|
||||
[tool.uv.sources]
|
||||
hindsight-api-slim = { workspace = true }
|
||||
|
||||
[project.scripts]
|
||||
hindsight-api = "hindsight_api.main:main"
|
||||
hindsight-worker = "hindsight_api.worker.main:main"
|
||||
hindsight-local-mcp = "hindsight_api.mcp_local:main"
|
||||
hindsight-admin = "hindsight_api.admin.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.19"
|
||||
version = "0.4.20"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -601,6 +601,13 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model_history(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model_history(bank_id, mental_model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Directive Methods ---
|
||||
|
||||
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
|
||||
|
||||
@@ -717,6 +717,13 @@ pub fn set_config(
|
||||
llm_model: Option<String>,
|
||||
llm_api_key: Option<String>,
|
||||
llm_base_url: Option<String>,
|
||||
retain_mission: Option<String>,
|
||||
retain_extraction_mode: Option<String>,
|
||||
observations_mission: Option<String>,
|
||||
reflect_mission: Option<String>,
|
||||
disposition_skepticism: Option<i64>,
|
||||
disposition_literalism: Option<i64>,
|
||||
disposition_empathy: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -736,9 +743,30 @@ pub fn set_config(
|
||||
if let Some(base_url) = llm_base_url {
|
||||
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
|
||||
}
|
||||
if let Some(mission) = retain_mission {
|
||||
updates.insert("retain_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(mode) = retain_extraction_mode {
|
||||
updates.insert("retain_extraction_mode".to_string(), serde_json::Value::String(mode));
|
||||
}
|
||||
if let Some(mission) = observations_mission {
|
||||
updates.insert("observations_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(mission) = reflect_mission {
|
||||
updates.insert("reflect_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(skepticism) = disposition_skepticism {
|
||||
updates.insert("disposition_skepticism".to_string(), serde_json::Value::Number(skepticism.into()));
|
||||
}
|
||||
if let Some(literalism) = disposition_literalism {
|
||||
updates.insert("disposition_literalism".to_string(), serde_json::Value::Number(literalism.into()));
|
||||
}
|
||||
if let Some(empathy) = disposition_empathy {
|
||||
updates.insert("disposition_empathy".to_string(), serde_json::Value::Number(empathy.into()));
|
||||
}
|
||||
|
||||
if updates.is_empty() {
|
||||
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --llm-api-key, or --llm-base-url".to_string()));
|
||||
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --retain-mission, --observations-mission, or other flags".to_string()));
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
|
||||
@@ -149,11 +149,12 @@ pub fn update(
|
||||
directive_id: &str,
|
||||
name: Option<String>,
|
||||
content: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && content.is_none() {
|
||||
anyhow::bail!("At least one of --name or --content must be provided");
|
||||
if name.is_none() && content.is_none() && is_active.is_none() {
|
||||
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
@@ -165,7 +166,7 @@ pub fn update(
|
||||
let request = types::UpdateDirectiveRequest {
|
||||
name,
|
||||
content,
|
||||
is_active: None,
|
||||
is_active,
|
||||
priority: None,
|
||||
tags: None,
|
||||
};
|
||||
|
||||
@@ -363,6 +363,9 @@ impl App {
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tag_groups: None,
|
||||
fact_types: None,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
};
|
||||
|
||||
let result = client.reflect(&bank_id, &request, false)
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
@@ -43,6 +43,16 @@ fn parse_budget(budget: &str) -> Budget {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to parse tags_match string to TagsMatch enum
|
||||
fn parse_tags_match(tags_match: &Option<String>) -> TagsMatch {
|
||||
match tags_match.as_deref().unwrap_or("any").to_lowercase().as_str() {
|
||||
"all" => TagsMatch::All,
|
||||
"any_strict" => TagsMatch::AnyStrict,
|
||||
"all_strict" => TagsMatch::AllStrict,
|
||||
_ => TagsMatch::Any,
|
||||
}
|
||||
}
|
||||
|
||||
/// List memory units with pagination and optional filters
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
@@ -250,6 +260,8 @@ pub fn recall(
|
||||
trace: bool,
|
||||
include_chunks: bool,
|
||||
chunk_max_tokens: i64,
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -280,8 +292,8 @@ pub fn recall(
|
||||
trace,
|
||||
query_timestamp: None,
|
||||
include,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
tag_groups: None,
|
||||
};
|
||||
|
||||
@@ -312,6 +324,9 @@ pub fn reflect(
|
||||
context: Option<String>,
|
||||
max_tokens: Option<i64>,
|
||||
schema_path: Option<PathBuf>,
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
include_facts: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -332,16 +347,28 @@ pub fn reflect(
|
||||
None
|
||||
};
|
||||
|
||||
let include = if include_facts {
|
||||
Some(ReflectIncludeOptions {
|
||||
facts: Some(FactsIncludeOptions(serde_json::Map::new())),
|
||||
tool_calls: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = ReflectRequest {
|
||||
query,
|
||||
budget: Some(parse_budget(&budget)),
|
||||
context,
|
||||
max_tokens: max_tokens.unwrap_or(4096),
|
||||
include: None,
|
||||
include,
|
||||
response_schema,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
tag_groups: None,
|
||||
fact_types: None,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
};
|
||||
|
||||
let response = client.reflect(agent_id, &request, verbose);
|
||||
|
||||
@@ -272,6 +272,55 @@ pub fn refresh(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the change history of a mental model
|
||||
pub fn history(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching mental model history..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_mental_model_history(bank_id, mental_model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(history) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("History: {}", mental_model_id));
|
||||
|
||||
if let Some(entries) = history.as_array() {
|
||||
if entries.is_empty() {
|
||||
println!(" {}", ui::dim("No history entries found."));
|
||||
} else {
|
||||
for entry in entries {
|
||||
let changed_at = entry.get("changed_at").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let previous = entry.get("previous_content").and_then(|v| v.as_str()).unwrap_or("(none)");
|
||||
println!(" {} {}", ui::dim("Changed at:"), changed_at);
|
||||
let preview: String = previous.chars().take(80).collect();
|
||||
let ellipsis = if previous.len() > 80 { "..." } else { "" };
|
||||
println!(" {} {}{}", ui::dim("Previous:"), ui::dim(&preview), ellipsis);
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&history, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to print mental model details
|
||||
fn print_mental_model_detail(mental_model: &types::MentalModelResponse) {
|
||||
ui::print_section_header(&mental_model.name);
|
||||
|
||||
@@ -310,6 +310,34 @@ enum BankCommands {
|
||||
/// LLM base URL override
|
||||
#[arg(long)]
|
||||
llm_base_url: Option<String>,
|
||||
|
||||
/// Retain mission: what to focus on during fact extraction
|
||||
#[arg(long)]
|
||||
retain_mission: Option<String>,
|
||||
|
||||
/// Retain extraction mode (concise, verbose, custom)
|
||||
#[arg(long)]
|
||||
retain_extraction_mode: Option<String>,
|
||||
|
||||
/// Observations mission: what to synthesize into durable observations
|
||||
#[arg(long)]
|
||||
observations_mission: Option<String>,
|
||||
|
||||
/// Reflect mission: first-person identity for reflect operations
|
||||
#[arg(long)]
|
||||
reflect_mission: Option<String>,
|
||||
|
||||
/// Disposition skepticism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_skepticism: Option<i64>,
|
||||
|
||||
/// Disposition literalism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_literalism: Option<i64>,
|
||||
|
||||
/// Disposition empathy trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_empathy: Option<i64>,
|
||||
},
|
||||
|
||||
/// Reset bank configuration to defaults (remove all overrides)
|
||||
@@ -387,6 +415,14 @@ enum MemoryCommands {
|
||||
/// Maximum tokens for chunks (only used with --include-chunks)
|
||||
#[arg(long, default_value = "8192")]
|
||||
chunk_max_tokens: i64,
|
||||
|
||||
/// Filter by tags (comma-separated, e.g. user:alice,team)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Vec<String>,
|
||||
|
||||
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
|
||||
#[arg(long)]
|
||||
tags_match: Option<String>,
|
||||
},
|
||||
|
||||
/// Generate answers using bank identity (reflect/reasoning)
|
||||
@@ -412,6 +448,18 @@ enum MemoryCommands {
|
||||
/// Path to JSON schema file for structured output
|
||||
#[arg(short = 's', long)]
|
||||
schema: Option<PathBuf>,
|
||||
|
||||
/// Filter by tags (comma-separated, e.g. user:alice,team)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Vec<String>,
|
||||
|
||||
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
|
||||
#[arg(long)]
|
||||
tags_match: Option<String>,
|
||||
|
||||
/// Include source facts (based_on) in the response
|
||||
#[arg(long)]
|
||||
include_facts: bool,
|
||||
},
|
||||
|
||||
/// Store (retain) a single memory
|
||||
@@ -678,6 +726,15 @@ enum MentalModelCommands {
|
||||
/// Mental model ID
|
||||
mental_model_id: String,
|
||||
},
|
||||
|
||||
/// Get the change history of a mental model
|
||||
History {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
mental_model_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -724,6 +781,10 @@ enum DirectiveCommands {
|
||||
/// New content
|
||||
#[arg(long)]
|
||||
content: Option<String>,
|
||||
|
||||
/// Enable or disable the directive
|
||||
#[arg(long)]
|
||||
is_active: Option<bool>,
|
||||
},
|
||||
|
||||
/// Delete a directive
|
||||
@@ -821,8 +882,8 @@ fn run() -> Result<()> {
|
||||
BankCommands::Config { bank_id, overrides_only } => {
|
||||
commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format)
|
||||
}
|
||||
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url } => {
|
||||
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, verbose, output_format)
|
||||
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy } => {
|
||||
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::ResetConfig { bank_id, yes } => {
|
||||
commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format)
|
||||
@@ -837,11 +898,11 @@ fn run() -> Result<()> {
|
||||
MemoryCommands::Get { bank_id, memory_id } => {
|
||||
commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format)
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, verbose, output_format)
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => {
|
||||
commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format)
|
||||
@@ -930,6 +991,9 @@ fn run() -> Result<()> {
|
||||
MentalModelCommands::Refresh { bank_id, mental_model_id } => {
|
||||
commands::mental_model::refresh(&client, &bank_id, &mental_model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::History { bank_id, mental_model_id } => {
|
||||
commands::mental_model::history(&client, &bank_id, &mental_model_id, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Directive commands
|
||||
@@ -943,8 +1007,8 @@ fn run() -> Result<()> {
|
||||
DirectiveCommands::Create { bank_id, name, content } => {
|
||||
commands::directive::create(&client, &bank_id, &name, &content, verbose, output_format)
|
||||
}
|
||||
DirectiveCommands::Update { bank_id, directive_id, name, content } => {
|
||||
commands::directive::update(&client, &bank_id, &directive_id, name, content, verbose, output_format)
|
||||
DirectiveCommands::Update { bank_id, directive_id, name, content, is_active } => {
|
||||
commands::directive::update(&client, &bank_id, &directive_id, name, content, is_active, verbose, output_format)
|
||||
}
|
||||
DirectiveCommands::Delete { bank_id, directive_id, yes } => {
|
||||
commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format)
|
||||
|
||||
@@ -7,7 +7,7 @@ info:
|
||||
name: Apache 2.0
|
||||
url: https://www.apache.org/licenses/LICENSE-2.0.html
|
||||
title: Hindsight HTTP API
|
||||
version: 0.4.19
|
||||
version: 0.4.20
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
@@ -3884,12 +3884,18 @@ components:
|
||||
loc:
|
||||
- ValidationError_loc_inner
|
||||
- ValidationError_loc_inner
|
||||
input: ""
|
||||
ctx: "{}"
|
||||
type: type
|
||||
url: url
|
||||
- msg: msg
|
||||
loc:
|
||||
- ValidationError_loc_inner
|
||||
- ValidationError_loc_inner
|
||||
input: ""
|
||||
ctx: "{}"
|
||||
type: type
|
||||
url: url
|
||||
properties:
|
||||
detail:
|
||||
items:
|
||||
@@ -4074,6 +4080,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4089,6 +4102,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4115,6 +4135,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4169,6 +4196,13 @@ components:
|
||||
description: Trigger settings for a mental model.
|
||||
example:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
properties:
|
||||
refresh_after_consolidation:
|
||||
default: false
|
||||
@@ -4176,6 +4210,26 @@ components:
|
||||
\ (real-time mode)"
|
||||
title: Refresh After Consolidation
|
||||
type: boolean
|
||||
fact_types:
|
||||
items:
|
||||
enum:
|
||||
- world
|
||||
- experience
|
||||
- observation
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
exclude_mental_models:
|
||||
default: false
|
||||
description: "If true, exclude all mental models from the reflect loop (skip\
|
||||
\ search_mental_models tool)."
|
||||
title: Exclude Mental Models
|
||||
type: boolean
|
||||
exclude_mental_model_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
title: MentalModelTrigger
|
||||
OperationResponse:
|
||||
description: Response model for a single async operation.
|
||||
@@ -4684,6 +4738,26 @@ components:
|
||||
$ref: '#/components/schemas/RecallRequest_tag_groups_inner'
|
||||
nullable: true
|
||||
type: array
|
||||
fact_types:
|
||||
items:
|
||||
enum:
|
||||
- world
|
||||
- experience
|
||||
- observation
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
exclude_mental_models:
|
||||
default: false
|
||||
description: "If true, exclude all mental models from the reflect loop (skip\
|
||||
\ search_mental_models tool)."
|
||||
title: Exclude Mental Models
|
||||
type: boolean
|
||||
exclude_mental_model_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- query
|
||||
title: ReflectRequest
|
||||
@@ -5134,7 +5208,10 @@ components:
|
||||
loc:
|
||||
- ValidationError_loc_inner
|
||||
- ValidationError_loc_inner
|
||||
input: ""
|
||||
ctx: "{}"
|
||||
type: type
|
||||
url: url
|
||||
properties:
|
||||
loc:
|
||||
items:
|
||||
@@ -5146,6 +5223,13 @@ components:
|
||||
type:
|
||||
title: Error Type
|
||||
type: string
|
||||
input: {}
|
||||
ctx:
|
||||
title: Context
|
||||
type: object
|
||||
url:
|
||||
title: URL
|
||||
type: string
|
||||
required:
|
||||
- loc
|
||||
- msg
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -41,7 +41,7 @@ var (
|
||||
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
|
||||
)
|
||||
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.4.19
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.4.20
|
||||
// In most cases there should be only one, shared, APIClient.
|
||||
type APIClient struct {
|
||||
cfg *Configuration
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user