Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50c38f8a53 | ||
|
|
ea8d88057b | ||
|
|
cf3bcee89c | ||
|
|
2d08352429 | ||
|
|
1c9ba0e659 | ||
|
|
a25b027759 | ||
|
|
0ca0226d36 | ||
|
|
60fea4c254 | ||
|
|
dff223f552 | ||
|
|
3b3f9de291 | ||
|
|
e6b2e4cb3e | ||
|
|
3fae76e392 | ||
|
|
2f075dedea | ||
|
|
08cfa5d369 | ||
|
|
0135fa39c9 | ||
|
|
4d799b5629 | ||
|
|
5e71cebc82 | ||
|
|
a92e25bcf5 | ||
|
|
8927ab73ae | ||
|
|
d0c54560ad | ||
|
|
946af18c91 | ||
|
|
abc1439675 | ||
|
|
c05ab9103f | ||
|
|
359b2bc762 | ||
|
|
78b1df8d8a | ||
|
|
989f30e215 | ||
|
|
d382b340f0 | ||
|
|
59d5319b84 | ||
|
|
460fc63d9b | ||
|
|
e20a36c959 | ||
|
|
13f3e081b7 | ||
|
|
20f1a77ea0 | ||
|
|
0a046f97ae | ||
|
|
156a543cf6 | ||
|
|
c3970cfd21 | ||
|
|
9500a6bf43 | ||
|
|
3d6b19af59 | ||
|
|
99fe231b36 | ||
|
|
9be7f59c03 | ||
|
|
42e72601f4 | ||
|
|
4835bf73d2 | ||
|
|
a38fc3453c | ||
|
|
6f59a09479 | ||
|
|
ea45930949 | ||
|
|
9a5aecd178 | ||
|
|
d81486ff9b | ||
|
|
f6710963e6 | ||
|
|
a63a0c0e70 | ||
|
|
30fb287d10 | ||
|
|
f0802b826b | ||
|
|
87448b1616 | ||
|
|
f304ce7e01 | ||
|
|
c6db44b101 | ||
|
|
7d57711e75 | ||
|
|
4d369a9a89 | ||
|
|
e24614db22 | ||
|
|
f5a6c300f1 | ||
|
|
443cce8146 | ||
|
|
54e1242193 | ||
|
|
9197498b70 | ||
|
|
b08f43496a | ||
|
|
f15b93f5cb | ||
|
|
5b9027ef16 | ||
|
|
b385393b6d | ||
|
|
8e6dc5fcd7 | ||
|
|
b5f97418cf | ||
|
|
c96106cc01 | ||
|
|
4032b27912 | ||
|
|
e8884bc0a0 | ||
|
|
259c82ec01 | ||
|
|
b83f621611 | ||
|
|
19e607b287 | ||
|
|
bc83eacc7b | ||
|
|
4e7780e593 | ||
|
|
e0221cae6e | ||
|
|
07c85da988 | ||
|
|
0afa046fc5 | ||
|
|
a1228ec3a7 | ||
|
|
f83fafa45b | ||
|
|
0a74ce3f07 | ||
|
|
5b5188af82 | ||
|
|
621ab7e66b | ||
|
|
72d9881a6a | ||
|
|
de22b606e7 | ||
|
|
51d25d84a3 | ||
|
|
c06e85b64f | ||
|
|
1d1f718ce5 | ||
|
|
a814b97197 | ||
|
|
61f980f5a3 | ||
|
|
fe404efb20 | ||
|
|
fb16fc4fdd | ||
|
|
82c7df7266 | ||
|
|
a0e6bedcf1 | ||
|
|
fd848a18c1 | ||
|
|
a0d91408cf | ||
|
|
0d55a9b78d | ||
|
|
90ee101bec | ||
|
|
27cb1c6843 | ||
|
|
a942b1c817 | ||
|
|
0280b3486f | ||
|
|
96910071c9 | ||
|
|
68d947b8db | ||
|
|
c815329c14 | ||
|
|
41b6e5746a | ||
|
|
85402035ed | ||
|
|
3f025c1fc3 | ||
|
|
37a20ec524 | ||
|
|
a67a8f774d | ||
|
|
91d767cdcb | ||
|
|
22ae72a907 | ||
|
|
4cb78173ba | ||
|
|
36e31c675c | ||
|
|
1c133dbd6c | ||
|
|
c6dd089445 | ||
|
|
ba44e0205d | ||
|
|
d5df9ad083 | ||
|
|
d7ff44b984 | ||
|
|
3069bb41af | ||
|
|
4dc149a1ac | ||
|
|
1296e9fc12 | ||
|
|
109e1bd955 | ||
|
|
c0f0c3a769 | ||
|
|
6ba4aeaf03 | ||
|
|
9ea1ef164a | ||
|
|
b0f86f9c0d | ||
|
|
9ca6617813 | ||
|
|
6dc56498ce | ||
|
|
9a9aef6225 | ||
|
|
66e58a23af | ||
|
|
e76021add3 | ||
|
|
ccf0dc8268 | ||
|
|
394d66e607 | ||
|
|
9891f53177 | ||
|
|
8170fe880e | ||
|
|
95d77233bf | ||
|
|
4a0a599473 | ||
|
|
bfdc1c5e65 | ||
|
|
37e28fac09 | ||
|
|
dbfe83a2ae | ||
|
|
7c2d1848ec | ||
|
|
644e37ac19 |
@@ -192,6 +192,18 @@ If a migration adds a new PostgreSQL table (look for `CREATE TABLE` / `op.create
|
||||
- The guard test `test_backup_tables_covers_entire_schema` in `tests/test_admin_backup_restore.py` enforces this — flag it as a **must fix** if a new table is absent from `BACKUP_TABLES`.
|
||||
- Oracle-only tables (e.g. `observation_sources`) are intentionally excluded — admin backup/restore is PostgreSQL-only.
|
||||
|
||||
### 11b. Check new config flags update the env template
|
||||
|
||||
If the diff adds a new configuration field (a new `ENV_*` / `HINDSIGHT_*` env var
|
||||
in `hindsight-api-slim/hindsight_api/config.py`):
|
||||
- **`.env.example`** (repo root) — must add the variable (commented if optional)
|
||||
alongside the docs entry in `hindsight-docs/docs/developer/configuration.md`.
|
||||
A flag added to `config.py` but absent from `.env.example` is a **should fix**.
|
||||
- **`hindsight-embed/hindsight_embed/env.example`** — the bundled copy must stay
|
||||
byte-identical to the repo-root `.env.example` (it seeds embed/profile configs).
|
||||
The `test_bundled_template_matches_repo_root` sync test fails on drift; if the
|
||||
root file changed without re-copying, flag it as a **must fix**.
|
||||
|
||||
### 12. Review against other coding standards
|
||||
|
||||
Check the diff for violations of the standards listed above:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: hs-release
|
||||
description: Cut a core Hindsight release (vX.Y.Z) and open the changelog + blog PR. Use when asked to cut/start a release, bump the version, or publish a new Hindsight version.
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
# Hindsight Release
|
||||
|
||||
Cut a **core** Hindsight release and open the accompanying changelog/blog PR. This is for the core
|
||||
product version (API, clients, CLI, control plane, Helm). **Integrations are versioned
|
||||
independently** — use `scripts/release-integration.sh` for those, not this skill.
|
||||
|
||||
The release is **irreversible and outward-facing**: it tags a version and pushes it straight to
|
||||
`main`, which triggers CI that publishes packages to PyPI / npm / Helm. Confirm the version number
|
||||
and that the intended fixes are already merged to `main` before you start.
|
||||
|
||||
## Step 0 — Pre-flight
|
||||
|
||||
1. **Decide the base.** A release is cut from the latest `origin/main`, never from a feature
|
||||
branch. `git fetch origin --tags` first. Confirm the "couple of fixes" the user means are
|
||||
actually merged to `main` (`git log v<prev>..origin/main --oneline`).
|
||||
2. **Find where `main` is checked out.** `main` is often already checked out in a sibling worktree
|
||||
(`git worktree list`). You **cannot** check out `main` in a second worktree — run the release in
|
||||
the worktree that already holds it. If that worktree is dirty with throwaway cruft
|
||||
(`.next-*` tsconfig paths, screenshots), `git stash push -u`, fast-forward to `origin/main`,
|
||||
run the release, then `git stash pop`.
|
||||
3. **Pitfall:** never pipe the checkout in an `&&` chain like
|
||||
`git checkout main 2>&1 | tail && git reset --hard ...` — the pipe's exit status is `tail`'s
|
||||
(always 0), so a failed checkout won't stop the chain and the `reset` fires on the **wrong
|
||||
branch**. Check out as its own command and verify `git branch --show-current` before resetting.
|
||||
|
||||
## Step 1 — Cut the release
|
||||
|
||||
Run from the worktree on a clean `main`:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <version> # e.g. 0.8.1 (no leading v)
|
||||
```
|
||||
|
||||
`release.sh` bumps the version in every component, regenerates the OpenAPI spec + all client SDKs,
|
||||
updates docs versioning, commits `Release v<version>`, tags `v<version>`, and **pushes the commit
|
||||
and tag directly to `main`**. The push triggers the `Release` GitHub Actions workflow that builds
|
||||
and publishes the packages. It is **not** a PR.
|
||||
|
||||
Verify after: `gh run list --limit 5` should show the `Release v<version>` workflow running, and
|
||||
`git ls-remote --tags origin v<version>` should return the tag.
|
||||
|
||||
## Step 2 — Changelog + blog PR (separate)
|
||||
|
||||
Done **after** the tag exists, as its own PR (precedent: v0.8.0 = #2053, v0.8.1 = #2080). Work on a
|
||||
branch off the new `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b docs-changelog-<version> origin/main
|
||||
```
|
||||
|
||||
Only spin up a separate worktree (`git worktree add ../hindsight-changelog-<version> -b
|
||||
docs-changelog-<version> origin/main`) if you can't get a clean checkout otherwise — e.g. `main` is
|
||||
held in another worktree and the current one has work you don't want to disturb.
|
||||
|
||||
**Branch naming:** use the `docs-` (hyphen) convention, e.g. `docs-changelog-0.8.1`. A remote
|
||||
branch literally named `docs` exists, so any `docs/...` branch is rejected on push with
|
||||
`directory file conflict`.
|
||||
|
||||
### Changelog
|
||||
|
||||
```bash
|
||||
uv run --directory hindsight-dev generate-changelog <version>
|
||||
```
|
||||
|
||||
LLM-summarizes the commits between the previous tag and `v<version>` and prepends an entry to
|
||||
`hindsight-docs/src/pages/changelog/index.md`. Requires `OPENAI_API_KEY` (already in the repo
|
||||
`.env`). It excludes `hindsight-integrations/` source, but new integrations whose commits also
|
||||
touched docs will still appear — that matches precedent, leave them in the **changelog**.
|
||||
|
||||
### Blog post
|
||||
|
||||
Hand-write `hindsight-docs/blog/YYYY-MM-DD-version-X-Y-Z.md` (mirror an existing one; patch
|
||||
releases are short — see `2026-06-02-version-0-7-2.md`). Guidance:
|
||||
|
||||
- **Explain user impact, not internals/mechanism.** Lead with what the user can now do and what to
|
||||
set. Config/env-var names are fine (developer-facing), code symbols and internals are not.
|
||||
- **Do not list integrations in the release blog.** The core blog covers core engine / API /
|
||||
ops changes; each integration ships its own changelog. (Integrations may still appear in the
|
||||
generated `changelog/index.md` — that's fine; just keep them out of the blog.)
|
||||
- Call out an upgrade recommendation when there are operational/data-integrity fixes.
|
||||
- Validate formatting: `npx prettier --check <blog file>`.
|
||||
|
||||
### Sync the docs skill
|
||||
|
||||
```bash
|
||||
./scripts/generate-docs-skill.sh
|
||||
```
|
||||
|
||||
Refreshes `skills/hindsight-docs/references/changelog/index.md`. It will also bump
|
||||
`skills/hindsight-docs/references/openapi.json` by one version — `release.sh` regenerates the skill
|
||||
*before* bumping OpenAPI, so the skill copy lags a version in the release commit; this step syncs
|
||||
it. Expect a one-line `version` diff there; keep it.
|
||||
|
||||
### Commit, push, PR
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit --no-verify -m "docs: changelog and blog post for v<version>"
|
||||
git push -u origin docs-changelog-<version>
|
||||
gh pr create --base main --title "docs: changelog and blog post for v<version>" --body "..."
|
||||
```
|
||||
|
||||
Expected files in the PR: the changelog entry, the new blog post, the regenerated skill changelog
|
||||
mirror, and the skill `openapi.json` version sync.
|
||||
|
||||
## Cleanup
|
||||
|
||||
If you created a temporary worktree, remove it once the PR is up
|
||||
(`git worktree remove ../hindsight-changelog-<version>`; the branch stays on origin). Restore any
|
||||
stash you popped in Step 0.
|
||||
@@ -47,6 +47,13 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
HINDSIGHT_API_HOST=0.0.0.0
|
||||
HINDSIGHT_API_PORT=8888
|
||||
HINDSIGHT_API_LOG_LEVEL=info
|
||||
# Optional retain chunking override for structured logs/transcripts.
|
||||
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
|
||||
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
|
||||
|
||||
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
|
||||
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
|
||||
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
|
||||
|
||||
# Base Path / Reverse Proxy Support (Optional)
|
||||
# Set these when deploying behind a reverse proxy with path-based routing
|
||||
@@ -59,6 +66,7 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
|
||||
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
|
||||
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
|
||||
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
|
||||
|
||||
# Vector Extension (Optional - uses pgvector by default)
|
||||
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
|
||||
|
||||
@@ -9,7 +9,11 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # for PyPI trusted publishing
|
||||
id-token: write # for PyPI trusted publishing + build-provenance attestations
|
||||
attestations: write # for actions/attest-build-provenance (Obsidian assets)
|
||||
# No `contents: write`: we never create releases in this repo. The Obsidian
|
||||
# plugin's distribution release is pushed to its dedicated repo using
|
||||
# OBSIDIAN_DIST_TOKEN (see the "Mirror Obsidian plugin" step below).
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -112,6 +116,71 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: npm run build
|
||||
|
||||
# Build-provenance attestations for the Obsidian release assets (community-store
|
||||
# recommendation). Runs after the build so main.js exists. The assets are
|
||||
# released in the dedicated repo while the build runs here, so users verify at
|
||||
# owner scope: `gh attestation verify main.js --owner vectorize-io`.
|
||||
- name: Attest Obsidian plugin build provenance
|
||||
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
hindsight-integrations/obsidian/main.js
|
||||
hindsight-integrations/obsidian/styles.css
|
||||
|
||||
# ── Obsidian plugin — mirror to its dedicated repo + cut the BRAT release ──
|
||||
# We do NOT create a GitHub Release in this monorepo: per-integration
|
||||
# releases pollute the repo's release list (it's for the core product) and
|
||||
# steal the "Latest" badge, and BRAT / the community store read a repo's
|
||||
# *latest* release — not a tag — so they can't target a tag in a monorepo.
|
||||
#
|
||||
# Instead this monorepo stays the source of truth, and on each obsidian
|
||||
# release we mirror hindsight-integrations/obsidian/ → the *root* of
|
||||
# github.com/vectorize-io/hindsight-obsidian (git subtree, history
|
||||
# preserved) and cut the BRAT / community-store release *there*.
|
||||
#
|
||||
# Requires secret OBSIDIAN_DIST_TOKEN — a token with `contents: write` on
|
||||
# vectorize-io/hindsight-obsidian (fine-grained PAT or app installation
|
||||
# token). The dedicated repo is generated; never edit it directly.
|
||||
- name: Mirror Obsidian plugin to its dedicated repo
|
||||
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
|
||||
env:
|
||||
DIST_TOKEN: ${{ secrets.OBSIDIAN_DIST_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.info.outputs.version }}"
|
||||
DIST_REPO="vectorize-io/hindsight-obsidian"
|
||||
OBS_DIR="hindsight-integrations/obsidian"
|
||||
|
||||
# `git subtree split` needs full history; the default checkout is shallow.
|
||||
git fetch --unshallow 2>/dev/null || true
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# The runner injects the default GITHUB_TOKEN as an http.extraheader via
|
||||
# an *included* config file (/home/runner/work/_temp/git-credentials-*.config),
|
||||
# so `git config --local --unset-all` can't remove it and it authenticates
|
||||
# the push as github-actions[bot] (no access to the dedicated repo → 403).
|
||||
# The documented way to drop an inherited extraheader is to RESET the list
|
||||
# with an empty value: since command-line `-c` is read last, the empty
|
||||
# value clears the accumulated headers (including the included one) at
|
||||
# request-build time. The dist token then comes from the push URL → a
|
||||
# single Authorization header.
|
||||
git subtree split --prefix="$OBS_DIR" -b _obs_dist
|
||||
git -c "http.https://github.com/.extraheader=" \
|
||||
push "https://x-access-token:${DIST_TOKEN}@github.com/${DIST_REPO}.git" _obs_dist:main
|
||||
|
||||
# Cut the BRAT / community-store release. Bare version tag (e.g. 0.1.0)
|
||||
# to match manifest.json — idempotent so re-runs just refresh the assets.
|
||||
export GH_TOKEN="$DIST_TOKEN"
|
||||
ASSETS="$OBS_DIR/main.js $OBS_DIR/manifest.json $OBS_DIR/styles.css"
|
||||
NOTES="Hindsight for Obsidian v${VERSION}. Install via BRAT (add ${DIST_REPO}) or copy main.js/manifest.json/styles.css into <vault>/.obsidian/plugins/hindsight/."
|
||||
if gh release view "$VERSION" --repo "$DIST_REPO" >/dev/null 2>&1; then
|
||||
gh release upload "$VERSION" $ASSETS --repo "$DIST_REPO" --clobber
|
||||
else
|
||||
gh release create "$VERSION" $ASSETS --repo "$DIST_REPO" --title "$VERSION" --notes "$NOTES"
|
||||
fi
|
||||
|
||||
- name: Publish TypeScript package to npm
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
@@ -121,7 +190,12 @@ jobs:
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
if echo "$OUTPUT" | grep -q "cannot publish over"; then
|
||||
# Treat "already published" as success so re-pointed-tag re-runs stay green.
|
||||
# "cannot publish over" = the version exists. TLOG_CREATE_ENTRY_ERROR / 409
|
||||
# "equivalent entry already exists in the transparency log" = the identical
|
||||
# --provenance artifact was already logged on a prior run (Sigstore tlog is
|
||||
# idempotent); the package is published, so this is benign.
|
||||
if echo "$OUTPUT" | grep -qE "cannot publish over|TLOG_CREATE_ENTRY_ERROR|already exists in the transparency log"; then
|
||||
echo "Package version already published, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
+509
-5
@@ -32,9 +32,14 @@ jobs:
|
||||
integration-tests: ${{ steps.filter.outputs.integration-tests }}
|
||||
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
|
||||
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
|
||||
integrations-agent-framework: ${{ steps.filter.outputs.integrations-agent-framework }}
|
||||
integrations-composio: ${{ steps.filter.outputs.integrations-composio }}
|
||||
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
|
||||
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
|
||||
integrations-cline: ${{ steps.filter.outputs.integrations-cline }}
|
||||
integrations-codex: ${{ steps.filter.outputs.integrations-codex }}
|
||||
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
|
||||
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
|
||||
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
|
||||
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
|
||||
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
|
||||
@@ -44,7 +49,9 @@ jobs:
|
||||
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
|
||||
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
|
||||
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
|
||||
integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }}
|
||||
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
|
||||
integrations-zapier: ${{ steps.filter.outputs.integrations-zapier }}
|
||||
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
|
||||
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
|
||||
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
|
||||
@@ -58,6 +65,9 @@ jobs:
|
||||
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
|
||||
integrations-flowise: ${{ steps.filter.outputs.integrations-flowise }}
|
||||
integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }}
|
||||
integrations-obsidian: ${{ steps.filter.outputs.integrations-obsidian }}
|
||||
integrations-omo: ${{ steps.filter.outputs.integrations-omo }}
|
||||
integrations-haystack: ${{ steps.filter.outputs.integrations-haystack }}
|
||||
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
|
||||
integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }}
|
||||
dev: ${{ steps.filter.outputs.dev }}
|
||||
@@ -118,12 +128,22 @@ jobs:
|
||||
- 'hindsight-integrations/openclaw/**'
|
||||
integrations-ai-sdk:
|
||||
- 'hindsight-integrations/ai-sdk/**'
|
||||
integrations-agent-framework:
|
||||
- 'hindsight-integrations/agent-framework/**'
|
||||
integrations-composio:
|
||||
- 'hindsight-integrations/composio/**'
|
||||
integrations-chat:
|
||||
- 'hindsight-integrations/chat/**'
|
||||
integrations-claude-code:
|
||||
- 'hindsight-integrations/claude-code/**'
|
||||
integrations-cline:
|
||||
- 'hindsight-integrations/cline/**'
|
||||
integrations-codex:
|
||||
- 'hindsight-integrations/codex/**'
|
||||
integrations-continue:
|
||||
- 'hindsight-integrations/continue/**'
|
||||
integrations-cursor-cli:
|
||||
- 'hindsight-integrations/cursor-cli/**'
|
||||
integrations-crewai:
|
||||
- 'hindsight-integrations/crewai/**'
|
||||
integrations-litellm:
|
||||
@@ -138,12 +158,18 @@ jobs:
|
||||
- 'hindsight-integrations/langgraph/**'
|
||||
integrations-llamaindex:
|
||||
- 'hindsight-integrations/llamaindex/**'
|
||||
integrations-haystack:
|
||||
- 'hindsight-integrations/haystack/**'
|
||||
integrations-paperclip:
|
||||
- 'hindsight-integrations/paperclip/**'
|
||||
integrations-opencode:
|
||||
- 'hindsight-integrations/opencode/**'
|
||||
integrations-cursor:
|
||||
- 'hindsight-integrations/cursor/**'
|
||||
integrations-n8n:
|
||||
- 'hindsight-integrations/n8n/**'
|
||||
integrations-zapier:
|
||||
- 'hindsight-integrations/zapier/**'
|
||||
integrations-cloudflare-oauth-proxy:
|
||||
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
|
||||
integrations-superagent:
|
||||
@@ -172,6 +198,10 @@ jobs:
|
||||
- 'hindsight-integrations/flowise/**'
|
||||
integrations-google-adk:
|
||||
- 'hindsight-integrations/google-adk/**'
|
||||
integrations-obsidian:
|
||||
- 'hindsight-integrations/obsidian/**'
|
||||
integrations-omo:
|
||||
- 'hindsight-integrations/omo/**'
|
||||
tools-agent-sdk:
|
||||
- 'hindsight-tools/hindsight-agent-sdk/**'
|
||||
integrations-roo-code:
|
||||
@@ -432,6 +462,95 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/claude-code
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
test-cursor-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-cursor == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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/cursor
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
test-omo-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-omo == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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/omo
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
test-cline-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-cline == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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 cline integration
|
||||
working-directory: ./hindsight-integrations/cline
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/cline
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/cline
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-codex-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -458,6 +577,43 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/codex
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
test-cursor-cli-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-cursor-cli == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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 cursor-cli integration
|
||||
working-directory: ./hindsight-integrations/cursor-cli
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/cursor-cli
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/cursor-cli
|
||||
run: uv run pytest tests -v
|
||||
|
||||
build-ai-sdk-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -583,6 +739,37 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/n8n
|
||||
run: npm run build
|
||||
|
||||
test-zapier-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-zapier == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/zapier
|
||||
run: npm install --no-fund --no-audit
|
||||
|
||||
- name: Validate app definition
|
||||
working-directory: ./hindsight-integrations/zapier
|
||||
run: npm run validate
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/zapier
|
||||
run: npm test
|
||||
|
||||
test-hindsight-agent-sdk:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -831,17 +1018,28 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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: '3.11'
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install pytest
|
||||
run: pip install pytest
|
||||
- name: Build roo-code integration
|
||||
working-directory: ./hindsight-integrations/roo-code
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/roo-code
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/roo-code
|
||||
run: python -m pytest tests/ -v
|
||||
run: uv run pytest tests -v
|
||||
|
||||
build-control-plane:
|
||||
needs: [detect-changes]
|
||||
@@ -2862,6 +3060,88 @@ jobs:
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-composio-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-composio == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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 composio integration
|
||||
working-directory: ./hindsight-integrations/composio
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/composio
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./hindsight-integrations/composio
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/composio
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-continue-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-continue == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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 continue integration
|
||||
working-directory: ./hindsight-integrations/continue
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/continue
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/continue
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-smolagents-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -2965,6 +3245,84 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/flowise
|
||||
run: npm test
|
||||
|
||||
test-obsidian-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-obsidian == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/obsidian
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Type check
|
||||
working-directory: ./hindsight-integrations/obsidian
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/obsidian
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/obsidian
|
||||
run: npm test
|
||||
|
||||
test-agent-framework-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-agent-framework == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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 agent-framework integration
|
||||
working-directory: ./hindsight-integrations/agent-framework
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/agent-framework
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./hindsight-integrations/agent-framework
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/agent-framework
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-crewai-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -3233,6 +3591,45 @@ jobs:
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-haystack-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-haystack == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- 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 haystack integration
|
||||
working-directory: ./hindsight-integrations/haystack
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/haystack
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/haystack
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-openai-agents-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -3416,6 +3813,49 @@ jobs:
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/slim-api-server.log 2>/dev/null || true
|
||||
|
||||
verify-embed-control-center-bundle:
|
||||
# The control center UI (Preact + Tailwind) is built with Vite and its static
|
||||
# output is committed (served as-is by the embed's Python http.server, no Node
|
||||
# at runtime). We can't byte-diff the committed bundle against a fresh build —
|
||||
# Vite's content-hashed asset filenames aren't reproducible across the CI
|
||||
# runner's OS/arch vs the committer's. So instead verify: (1) the committed
|
||||
# bundle is a real, wired Vite build (index.html references JS/CSS that exist),
|
||||
# and (2) the source still builds cleanly.
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.embed == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Check the committed bundle BEFORE building (the build overwrites static/).
|
||||
- name: Verify the committed bundle is wired
|
||||
working-directory: ./hindsight-embed/hindsight_embed/control_center
|
||||
run: |
|
||||
test -f static/index.html || { echo "::error::static/index.html missing — run 'npm run build' in control_center/ui and commit static/"; exit 1; }
|
||||
js=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.js' static/index.html | head -1)
|
||||
css=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.css' static/index.html | head -1)
|
||||
{ [ -n "$js" ] && [ -f "static/$js" ]; } || { echo "::error::index.html does not reference a committed JS bundle — rebuild the UI and commit static/"; exit 1; }
|
||||
{ [ -n "$css" ] && [ -f "static/$css" ]; } || { echo "::error::index.html does not reference a committed CSS bundle — rebuild the UI and commit static/"; exit 1; }
|
||||
echo "committed bundle is wired ✓"
|
||||
|
||||
- name: Verify the source builds cleanly
|
||||
working-directory: ./hindsight-embed/hindsight_embed/control_center/ui
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
echo "control center UI builds ✓"
|
||||
|
||||
test-embed:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -3631,7 +4071,8 @@ jobs:
|
||||
target="$RUNNER_TEMP/install-test"
|
||||
PYTHONPATH="$target" python -c "
|
||||
from hindsight_embed.daemon_embed_manager import DaemonEmbedManager
|
||||
cmd = DaemonEmbedManager()._find_api_command()
|
||||
# api_version is only used for the uvx fallback; the binary branch ignores it.
|
||||
cmd = DaemonEmbedManager()._find_api_command('0.0.0')
|
||||
print('Resolved command:', cmd)
|
||||
assert len(cmd) == 1 and cmd[0].endswith('hindsight-api.exe'), (
|
||||
f'Expected sibling hindsight-api.exe, got {cmd!r}. '
|
||||
@@ -3992,6 +4433,60 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# Dead-code detection beyond what ruff's F401/F841 catch (those are already
|
||||
# BLOCKING via the ruff config + the verify-generated-files job).
|
||||
#
|
||||
# - knip (control plane): BLOCKING on unused files / dependencies / unlisted
|
||||
# dependencies. These are unambiguous — an orphaned file or a dead
|
||||
# package.json entry — so they fail the build.
|
||||
# - vulture (Python) + knip unused *exports*: ADVISORY only. vulture's
|
||||
# function/argument heuristics false-positive on FastAPI/SQLAlchemy/Pydantic
|
||||
# patterns, and the control plane intentionally keeps an unused shadcn/ui
|
||||
# component surface, so these are surfaced in the step summary, not gated.
|
||||
check-unused-code:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.control-plane == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install Control Plane dependencies
|
||||
run: npm install --workspace=hindsight-control-plane
|
||||
|
||||
- name: knip — unused files / dependencies (blocking)
|
||||
working-directory: hindsight-control-plane
|
||||
run: npx --yes knip@5 --no-progress --include files,dependencies,unlisted
|
||||
|
||||
- name: Advisory scan — vulture + knip exports
|
||||
continue-on-error: true
|
||||
run: |
|
||||
{
|
||||
echo '## Dead-code scan (advisory)'
|
||||
echo ''
|
||||
echo '```'
|
||||
./scripts/hooks/check-unused.sh 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
|
||||
echo '```'
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
verify-generated-files:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
@@ -4173,10 +4668,14 @@ jobs:
|
||||
- build-openclaw-integration
|
||||
- smoke-openclaw-install
|
||||
- test-claude-code-integration
|
||||
- test-cursor-integration
|
||||
- test-cline-integration
|
||||
- test-codex-integration
|
||||
- test-cursor-cli-integration
|
||||
- build-ai-sdk-integration
|
||||
- test-ai-sdk-integration-deno
|
||||
- test-opencode-integration
|
||||
- test-omo-integration
|
||||
- test-cloudflare-oauth-proxy-integration
|
||||
- build-chat-integration
|
||||
- test-paperclip-integration
|
||||
@@ -4205,9 +4704,12 @@ jobs:
|
||||
- test-integration
|
||||
- test-ag2-integration
|
||||
- test-autogen-integration
|
||||
- test-continue-integration
|
||||
- test-smolagents-integration
|
||||
- test-dify-integration
|
||||
- test-flowise-integration
|
||||
- test-obsidian-integration
|
||||
- test-agent-framework-integration
|
||||
- test-crewai-integration
|
||||
- test-langgraph-integration
|
||||
- test-superagent-integration
|
||||
@@ -4216,9 +4718,11 @@ jobs:
|
||||
- test-llamaindex-integration
|
||||
- test-openai-agents-integration
|
||||
- test-agentcore-integration
|
||||
- test-haystack-integration
|
||||
- test-pip-slim
|
||||
- test-embed
|
||||
- test-embed-windows
|
||||
- verify-embed-control-center-bundle
|
||||
- test-hindsight-all
|
||||
- test-hindsight-agent-sdk
|
||||
- test-claude-agent-sdk-integration
|
||||
|
||||
+4
-1
@@ -15,6 +15,8 @@ node_modules/
|
||||
|
||||
# Environment variables and local config
|
||||
.env
|
||||
.env.bak*
|
||||
.env.*.bak
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -59,4 +61,5 @@ hindsight-integrations/_drafts/
|
||||
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
|
||||
# CHANGELOG.md
|
||||
|
||||
blog-post*
|
||||
blog-post*
|
||||
.worktrees/
|
||||
|
||||
@@ -216,6 +216,18 @@ migration file dispatches through `run_for_dialect`, which calls either
|
||||
./scripts/hooks/lint.sh
|
||||
```
|
||||
|
||||
Dead-code detection runs in CI (the `check-unused-code` job) at two levels:
|
||||
- **Blocking:** unused imports (ruff `F401`) and variables (`F841`) — `lint.sh` auto-removes
|
||||
them and `verify-generated-files` fails on any leftover diff; and **knip** for orphaned
|
||||
control-plane files / unused (or unlisted) `package.json` dependencies.
|
||||
- **Advisory:** whole unused Python functions (vulture) and unused control-plane *exports*
|
||||
(the shadcn/ui surface is kept on purpose) — surfaced, not gated.
|
||||
|
||||
Run both locally with:
|
||||
```bash
|
||||
./scripts/hooks/check-unused.sh
|
||||
```
|
||||
|
||||
**After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done.
|
||||
|
||||
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
|
||||
@@ -315,7 +327,10 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
|
||||
```
|
||||
|
||||
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
|
||||
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
|
||||
- No change is needed for ordinary environment-backed config fields. The CLI starts from `_get_raw_config()`,
|
||||
so new `HindsightConfig` fields are carried through automatically.
|
||||
- If the new field should be overridable by a CLI flag, add the argparse option in `_parse_cli_args()` and include
|
||||
that field in the `dataclasses.replace(config, ...)` call near the "CLI override" comment.
|
||||
|
||||
3. **Use hierarchical config in MemoryEngine**:
|
||||
```python
|
||||
@@ -335,6 +350,16 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
|
||||
- Add to appropriate section table with Variable, Description, Default
|
||||
- Mark if it's hierarchical (can be overridden per-bank)
|
||||
|
||||
6. **Env template** (`.env.example`):
|
||||
- Add the variable to the appropriate section, commented if optional, with a
|
||||
short inline comment describing it (mirror the documentation entry).
|
||||
- This file is the single source of truth for the env template:
|
||||
`scripts/dev/setup.sh` copies it to `.env`, and `hindsight-embed` ships a
|
||||
bundled copy (`hindsight-embed/hindsight_embed/env.example`) that seeds
|
||||
embed/profile configs. After editing `.env.example`, re-copy it to the
|
||||
embed package (`cp .env.example hindsight-embed/hindsight_embed/env.example`)
|
||||
or the `test_bundled_template_matches_repo_root` sync test will fail.
|
||||
|
||||
#### Hierarchical vs Static Guidelines
|
||||
|
||||
**Hierarchical** (per-bank overridable):
|
||||
@@ -351,7 +376,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with LLM API key
|
||||
# Edit .env with the LLM provider/model and credentials for your setup
|
||||
|
||||
# Python deps
|
||||
uv sync --directory hindsight-api-slim/
|
||||
@@ -360,10 +385,10 @@ uv sync --directory hindsight-api-slim/
|
||||
npm install
|
||||
```
|
||||
|
||||
Required env vars:
|
||||
Common LLM settings:
|
||||
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio
|
||||
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
|
||||
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
|
||||
- `HINDSIGHT_API_LLM_API_KEY`: API key for providers that require one
|
||||
- `HINDSIGHT_API_LLM_MODEL`: Model name (defaults are provider-specific)
|
||||
|
||||
Optional (uses local models by default):
|
||||
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
[](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/>
|
||||
@@ -143,6 +142,8 @@ main();
|
||||
pip install hindsight-all -U
|
||||
```
|
||||
|
||||
On Intel (x86_64) Macs, install `hindsight-all-slim` instead — see [Supported Platforms](#supported-platforms).
|
||||
|
||||
```python
|
||||
import os
|
||||
from hindsight import HindsightServer, HindsightClient
|
||||
@@ -300,6 +301,19 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
|
||||
[](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
|
||||
---
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|
||||
|----------|--------|------------------|--------------------|
|
||||
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
|
||||
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
|
||||
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
|
||||
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
|
||||
|
||||
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
|
||||
"npm:@radix-ui/react-label@^2.1.8",
|
||||
"npm:@radix-ui/react-popover@^1.1.15",
|
||||
"npm:@radix-ui/react-radio-group@^1.3.8",
|
||||
"npm:@radix-ui/react-select@^2.2.6",
|
||||
"npm:@radix-ui/react-slider@^1.3.6",
|
||||
"npm:@radix-ui/react-slot@^1.2.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: hindsight
|
||||
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
|
||||
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
|
||||
# docker compose -f docker/docker-compose/vchord/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/vchord/docker-compose.yaml up -d
|
||||
# Make sure to set the required environment variables before running:
|
||||
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
|
||||
# - Configure LLM provider variables as needed (see below in the hindsight service)
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.8.0
|
||||
appVersion: "0.8.0"
|
||||
version: 0.8.2
|
||||
appVersion: "0.8.2"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -66,13 +66,13 @@ helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f value
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `version` | Default image tag for all components | `0.1.0` |
|
||||
| `version` | Default image tag for all components | Chart `appVersion` |
|
||||
| `api.enabled` | Enable the API component | `true` |
|
||||
| `api.image.repository` | API image repository | `hindsight/api` |
|
||||
| `api.image.repository` | API image repository | `ghcr.io/vectorize-io/hindsight-api` |
|
||||
| `api.image.tag` | API image tag (defaults to `version`) | - |
|
||||
| `api.service.port` | API service port | `8888` |
|
||||
| `controlPlane.enabled` | Enable the control plane | `true` |
|
||||
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
|
||||
| `controlPlane.image.repository` | Control plane image repository | `ghcr.io/vectorize-io/hindsight-control-plane` |
|
||||
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
|
||||
| `controlPlane.service.port` | Control plane service port | `3000` |
|
||||
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
# - Any other env vars you want to inject
|
||||
# existingSecret: "my-hindsight-secret"
|
||||
|
||||
# Global settings
|
||||
replicaCount: 1
|
||||
|
||||
# Image settings for api
|
||||
api:
|
||||
enabled: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-all",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all-slim"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"hindsight-api-slim==0.8.0",
|
||||
"hindsight-api-slim==0.8.2",
|
||||
"hindsight-client>=0.0.7",
|
||||
"hindsight-embed>=0.1.0",
|
||||
]
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"hindsight-api-slim[all]==0.8.0",
|
||||
"hindsight-api-slim[all]==0.8.2",
|
||||
"hindsight-client>=0.0.7",
|
||||
"hindsight-embed>=0.1.0",
|
||||
]
|
||||
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
local-llm = [
|
||||
"hindsight-api-slim[local-llm]==0.8.0",
|
||||
"hindsight-api-slim[local-llm]==0.8.2",
|
||||
]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
|
||||
@@ -53,4 +53,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.8.0"
|
||||
__version__ = "0.8.2"
|
||||
|
||||
@@ -54,23 +54,20 @@ _INDEX_TYPE_KEYWORDS = {
|
||||
# pre-dispatcher code (internal benchmarks tuned around our embedding count
|
||||
# and recall floor; see the link_utils / pool init call sites for the
|
||||
# latency-vs-recall framing).
|
||||
# - vchord exposes vchordrq.probes (no default; see VectorChord issue #392)
|
||||
# and vchordrq.epsilon (default 1.9). probes = 10 / 30 are starting
|
||||
# defaults pending a workload-specific sweep — vchordrq's recall curve
|
||||
# shape differs from HNSW's, so the pgvector numbers don't translate
|
||||
# directly. Revisit with a per-cluster benchmark once we have production
|
||||
# recall data; until then these are deliberately conservative on the
|
||||
# high-recall path. We leave epsilon at its default; tightening it is a
|
||||
# separate trade-off.
|
||||
# - vchord exposes vchordrq.probes, but its shape must match the index's
|
||||
# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback
|
||||
# parameters for this reason: a session GUC overrides every vchordrq index,
|
||||
# and a single value can be invalid for listless or mixed-layout indexes.
|
||||
# Hindsight's built-in vchord clause does not set lists, so the safe default
|
||||
# is no session-level probe override; deployments that partition vchordrq
|
||||
# indexes should attach probes to the index storage parameters instead.
|
||||
# - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement
|
||||
# knob in the engine today, so the dispatcher returns no statements for them.
|
||||
_ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
"pgvector": (("hnsw.ef_search", "60"),),
|
||||
"vchord": (("vchordrq.probes", "10"),),
|
||||
}
|
||||
_ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
"pgvector": (("hnsw.ef_search", "200"),),
|
||||
"vchord": (("vchordrq.probes", "30"),),
|
||||
}
|
||||
|
||||
_EXTENSION_INSTALL_SQL = {
|
||||
|
||||
@@ -49,6 +49,7 @@ BACKUP_TABLES = [
|
||||
"entities",
|
||||
"chunks",
|
||||
"memory_units",
|
||||
"invalidated_memory_units",
|
||||
"unit_entities",
|
||||
"entity_cooccurrences",
|
||||
"memory_links",
|
||||
@@ -257,12 +258,7 @@ async def _run_migration(
|
||||
embedding_dimension: int | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
|
||||
from ..migrations import (
|
||||
ensure_embedding_dimension,
|
||||
ensure_text_search_extension,
|
||||
ensure_vector_extension,
|
||||
run_migrations,
|
||||
)
|
||||
from ..migrations import run_migrations_for_schemas
|
||||
|
||||
is_pg0, instance_name, _ = parse_pg0_url(db_url)
|
||||
if is_pg0:
|
||||
@@ -283,32 +279,21 @@ async def _run_migration(
|
||||
# Preserve order while removing duplicates.
|
||||
schemas = list(dict.fromkeys(schemas))
|
||||
|
||||
for schema in schemas:
|
||||
run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url)
|
||||
|
||||
if embedding_dimension is not None:
|
||||
for schema in schemas:
|
||||
ensure_embedding_dimension(
|
||||
resolved_url,
|
||||
embedding_dimension,
|
||||
schema=schema,
|
||||
vector_extension=config.vector_extension,
|
||||
)
|
||||
|
||||
for schema in schemas:
|
||||
ensure_vector_extension(
|
||||
resolved_url,
|
||||
vector_extension=config.vector_extension,
|
||||
schema=schema,
|
||||
)
|
||||
|
||||
for schema in schemas:
|
||||
ensure_text_search_extension(
|
||||
resolved_url,
|
||||
text_search_extension=config.text_search_extension,
|
||||
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
|
||||
schema=schema,
|
||||
)
|
||||
# Migrate up to `migration_concurrency` schemas at once (each in its own
|
||||
# process); within a schema the work stays sequential. Run off the event
|
||||
# loop so the process pool's blocking joins don't stall it.
|
||||
await asyncio.to_thread(
|
||||
run_migrations_for_schemas,
|
||||
resolved_url,
|
||||
schemas,
|
||||
concurrency=config.migration_concurrency,
|
||||
migration_database_url=config.migration_database_url,
|
||||
embedding_dimension=embedding_dimension,
|
||||
vector_extension=config.vector_extension,
|
||||
text_search_extension=config.text_search_extension,
|
||||
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
|
||||
ensure_extensions=True,
|
||||
)
|
||||
|
||||
return schemas
|
||||
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
"""Add a composite index on memory_links(bank_id, link_type) (PostgreSQL).
|
||||
|
||||
``bank_id`` was added to ``memory_links`` in ``c5d6e7f8a9b0`` precisely so that
|
||||
bank-scoped reads (e.g. the stats endpoint) could filter on the link table
|
||||
directly instead of joining ``memory_units`` — that JOIN took 18+ seconds on
|
||||
banks with millions of links. The column landed without an index, so every
|
||||
``bank_id = $1`` predicate still falls back to a sequential scan over the whole
|
||||
table.
|
||||
|
||||
This adds the missing btree. It is composite on ``(bank_id, link_type)`` rather
|
||||
than ``bank_id`` alone because the hot query is the stats endpoint's
|
||||
``SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type``: a
|
||||
``(bank_id, link_type)`` index serves that filter, grouping and count as an
|
||||
index-only scan, never touching the heap, whereas a ``bank_id``-only index would
|
||||
still have to read every matching row to recover ``link_type``. ``link_type`` is
|
||||
low-cardinality (only ``temporal``/``semantic``/``caused_by`` are written —
|
||||
entity edges were dropped in ``e9b2c7d1f3a4``), so the trailing column adds
|
||||
little to the index size while removing the heap fetch.
|
||||
|
||||
The Oracle baseline (``o1a2b3c4d5e6``) already creates ``idx_ml_bank_id`` on
|
||||
``memory_links(bank_id)``; that single-column index already covers Oracle's
|
||||
bank-scoped filter, so the Oracle slot here is intentionally absent and only the
|
||||
PostgreSQL dialect gets the composite index.
|
||||
|
||||
``memory_links`` can hold tens of millions of rows, so the index is built
|
||||
CONCURRENTLY to avoid taking a write lock on the table. CONCURRENTLY cannot run
|
||||
inside a transaction block, so the statement runs in an ``autocommit_block()``;
|
||||
``IF NOT EXISTS`` keeps it idempotent across retries and re-migrated tenant
|
||||
schemas. A CONCURRENTLY build interrupted partway (lock conflict, disk
|
||||
pressure, signal) leaves the index behind as *invalid*; ``IF NOT EXISTS`` would
|
||||
then skip over it forever, so the upgrade first drops any invalid leftover of
|
||||
this name before (re)creating it.
|
||||
|
||||
Revision ID: 2071c7518f88
|
||||
Revises: a1d3f5b7c9e2
|
||||
Create Date: 2026-06-16
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
from sqlalchemy import text
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "2071c7518f88"
|
||||
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_INDEX_NAME = "idx_memory_links_bank_id_link_type"
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# `or None` collapses an unset option and an explicit empty string into NULL
|
||||
# so the COALESCE below falls back to current_schema() in both cases.
|
||||
target_schema = context.config.get_main_option("target_schema") or None
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
|
||||
# autocommit_block runs each statement outside Alembic's migration
|
||||
# transaction.
|
||||
with op.get_context().autocommit_block():
|
||||
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
|
||||
# index of this name behind. `CREATE INDEX ... IF NOT EXISTS` would see
|
||||
# that relation and skip, so bank_id queries would keep seq-scanning.
|
||||
# Drop only the invalid leftover — never a healthy index — so the retry
|
||||
# actually rebuilds a usable one.
|
||||
leftover_invalid = bind.execute(
|
||||
text(
|
||||
"SELECT NOT i.indisvalid "
|
||||
"FROM pg_class c "
|
||||
"JOIN pg_index i ON c.oid = i.indexrelid "
|
||||
"JOIN pg_namespace n ON c.relnamespace = n.oid "
|
||||
"WHERE c.relname = :index_name "
|
||||
" AND n.nspname = COALESCE(:target_schema, current_schema())"
|
||||
),
|
||||
{"index_name": _INDEX_NAME, "target_schema": target_schema},
|
||||
).scalar()
|
||||
if leftover_invalid:
|
||||
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
|
||||
|
||||
# IF NOT EXISTS keeps the create idempotent across retries and schemas.
|
||||
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX_NAME} ON {schema}memory_links(bank_id, link_type)")
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
"""Repair: widen the remaining live ``bank_id`` columns from VARCHAR(64) to TEXT on PostgreSQL.
|
||||
|
||||
Follow-up to ``c3e5a7b9d1f4`` (issue #2106), which widened the two *history*
|
||||
tables (``observation_history``, ``mental_model_history``) to ``TEXT`` after the
|
||||
narrow ``VARCHAR(64)`` declaration bricked startup. The same VARCHAR(64) / TEXT
|
||||
inconsistency still affects the live tables that store a user-supplied
|
||||
``bank_id``:
|
||||
|
||||
* ``directives`` -- created VARCHAR(64) in ``p1k2l3m4n5o6``
|
||||
* ``mental_models`` -- VARCHAR(64) (origin ``pinned_reflections`` in
|
||||
``n9i0j1k2l3m4``; recreated in ``h3c4d5e6f7g8``)
|
||||
|
||||
``mental_model_versions`` is intentionally *not* widened here: it is created in
|
||||
``j5e6f7g8h9i0`` but dropped (``DROP TABLE ... CASCADE``) in ``o0j1k2l3m4n5`` and
|
||||
never recreated on the upgrade path, so it does not exist at head. Issuing
|
||||
``ALTER TABLE mental_model_versions ...`` would raise ``UndefinedTable`` and --
|
||||
because migrations run inside the lifespan-startup transaction -- roll the whole
|
||||
migration back, bricking the API. (It is unrelated to the live
|
||||
``mental_model_history`` table widened by ``c3e5a7b9d1f4``.)
|
||||
|
||||
``banks.bank_id`` is ``TEXT`` (unbounded), so a deployment can create a bank
|
||||
whose id exceeds 64 chars -- the 78-char hierarchical org-unit shape reported in
|
||||
issue #2106 -- and the bank insert succeeds. The next write that propagates that
|
||||
id (``create_directive``, ``create_mental_model`` / consolidation, or
|
||||
mental-model versioning) then aborts with::
|
||||
|
||||
psycopg2.errors.StringDataRightTruncation: value too long for type
|
||||
character varying(64)
|
||||
|
||||
i.e. a 500 on core write endpoints, instead of the startup brick that
|
||||
``c3e5a7b9d1f4`` already repaired.
|
||||
|
||||
``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is already ``TEXT``,
|
||||
so every upgrade path converges on ``TEXT``. These tables are per-tenant (they
|
||||
live in each tenant schema, not ``public``), so this runs for every migrated
|
||||
schema via the search-path-aware prefix -- the same mechanism as
|
||||
``c3e5a7b9d1f4``.
|
||||
|
||||
PostgreSQL only: these tables are created by PostgreSQL-only migrations
|
||||
(``run_for_dialect(pg=...)``); on Oracle they are absent or already
|
||||
``VARCHAR2(256)`` (consistent, never truncates), so the Oracle slot is
|
||||
intentionally absent -- mirroring ``c3e5a7b9d1f4``.
|
||||
|
||||
Revision ID: a1d3f5b7c9e2
|
||||
Revises: c3e5a7b9d1f4
|
||||
Create Date: 2026-06-13
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "a1d3f5b7c9e2"
|
||||
down_revision: str | Sequence[str] | None = "c3e5a7b9d1f4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}directives ALTER COLUMN bank_id TYPE TEXT")
|
||||
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN bank_id TYPE TEXT")
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
|
||||
# re-introduce the bug this migration repairs. The column types are owned by
|
||||
# the migrations that created the tables.
|
||||
pass
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
+2
-2
@@ -62,7 +62,7 @@ def _pg_upgrade() -> None:
|
||||
CREATE TABLE IF NOT EXISTS {schema}mental_model_history (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
mental_model_id VARCHAR(64) NOT NULL,
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
bank_id TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (mental_model_id, bank_id)
|
||||
@@ -83,7 +83,7 @@ def _pg_upgrade() -> None:
|
||||
CREATE TABLE IF NOT EXISTS {schema}observation_history (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
observation_id UUID NOT NULL,
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
bank_id TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (observation_id)
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
"""Repair: install maintenance routines on the ``public`` / base-schema run.
|
||||
|
||||
The original maintenance-routines migration (``e5f6a7b8c9d0``) only created the
|
||||
shared ``public.banks_needing_consolidation()`` and
|
||||
``public.schemas_with_expired_rows(...)`` routines when the run had *no*
|
||||
``target_schema`` at all. But the single-tenant runtime always migrates an
|
||||
explicit schema — which defaults to ``public`` — so on every default
|
||||
PostgreSQL deployment the migration was stamped as applied while the functions
|
||||
were never created. Background maintenance then logs::
|
||||
|
||||
Retention sweep failed for llm_requests: function public.schemas_with_expired_rows(...) does not exist
|
||||
Consolidation reconcile discovery failed: function public.banks_needing_consolidation() does not exist
|
||||
|
||||
See https://github.com/vectorize-io/hindsight/issues/2056.
|
||||
|
||||
Because ``e5f6a7b8c9d0`` is already stamped on affected ``0.8.0`` databases,
|
||||
editing it would not re-run it there. This forward migration re-installs the
|
||||
functions idempotently (``CREATE OR REPLACE``) on the run that targets the
|
||||
shared ``public`` schema (base run with no ``target_schema``, or an explicit
|
||||
``target_schema=public``), self-healing already-upgraded deployments and
|
||||
covering fresh upgrades from earlier versions.
|
||||
|
||||
Per-tenant runs against a non-``public`` schema still skip it: re-issuing
|
||||
``CREATE OR REPLACE FUNCTION public....`` from each concurrent tenant migration
|
||||
aborts with ``tuple concurrently updated`` on the ``pg_proc`` catalog row, and
|
||||
the base/public run has already created the functions for every tenant to use.
|
||||
Runs that target ``public`` are serialized by the per-schema migration advisory
|
||||
lock, so only one wins the create.
|
||||
|
||||
PostgreSQL only — the worker poller and these tables are not wired for Oracle,
|
||||
so the Oracle slot is intentionally absent (mirrors ``e5f6a7b8c9d0``).
|
||||
|
||||
Revision ID: b2d4f6a8c1e3
|
||||
Revises: e5f6a7b8c9d0
|
||||
Create Date: 2026-06-08
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "b2d4f6a8c1e3"
|
||||
down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _should_install_public_routines(target_schema: str | None) -> bool:
|
||||
"""True for the run that must (re)create the shared ``public.*`` routines.
|
||||
|
||||
The routines physically live in ``public`` (hard-coded ``public.`` qualifier
|
||||
in the SQL below), so they must be installed exactly once — on the base run
|
||||
(no ``target_schema``) or on the run that explicitly targets ``public``. A
|
||||
run against any other tenant schema skips it to avoid concurrent
|
||||
``CREATE OR REPLACE`` on the same ``pg_proc`` row.
|
||||
"""
|
||||
return not target_schema or target_schema == "public"
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
|
||||
return
|
||||
# Banks with eligible-but-unscheduled facts and no in-flight consolidation.
|
||||
# Auto-consolidation is filtered here only at the bank level (cheap prune);
|
||||
# the full hierarchical resolution (global -> tenant -> bank, plus
|
||||
# enable_observations) is done by the caller for the small returned set.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
|
||||
RETURNS TABLE(schema_name text, bank_id text)
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $fn$
|
||||
DECLARE
|
||||
sch text;
|
||||
BEGIN
|
||||
FOR sch IN
|
||||
SELECT n.nspname
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
|
||||
LOOP
|
||||
RETURN QUERY EXECUTE format($q$
|
||||
SELECT %1$L::text, m.bank_id
|
||||
FROM %1$I.memory_units m
|
||||
JOIN %1$I.banks b ON b.bank_id = m.bank_id
|
||||
WHERE m.consolidated_at IS NULL
|
||||
AND m.consolidation_failed_at IS NULL
|
||||
AND m.fact_type IN ('experience', 'world')
|
||||
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM %1$I.async_operations o
|
||||
WHERE o.bank_id = m.bank_id
|
||||
AND o.operation_type = 'consolidation'
|
||||
AND o.status IN ('pending', 'processing')
|
||||
)
|
||||
GROUP BY m.bank_id
|
||||
$q$, sch);
|
||||
END LOOP;
|
||||
END;
|
||||
$fn$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Schemas holding at least one row of p_table older than p_days. p_ts_col is
|
||||
# the timestamp column to compare. Returns nothing when p_days <= 0
|
||||
# (retention disabled).
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
|
||||
p_table text, p_ts_col text, p_days int
|
||||
)
|
||||
RETURNS SETOF text
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $fn$
|
||||
DECLARE
|
||||
sch text;
|
||||
has_expired boolean;
|
||||
BEGIN
|
||||
IF p_days IS NULL OR p_days <= 0 THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
FOR sch IN
|
||||
SELECT n.nspname
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = p_table AND c.relkind = 'r'
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
|
||||
sch, p_table, p_ts_col
|
||||
) INTO has_expired USING p_days;
|
||||
IF has_expired THEN
|
||||
RETURN NEXT sch;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$fn$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
# No-op: ``e5f6a7b8c9d0`` owns the lifecycle of these functions and drops
|
||||
# them on its own downgrade. This migration only ever (re)creates them, so
|
||||
# there is nothing to undo without racing that migration's DROP.
|
||||
pass
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"""Repair: widen ``*_history.bank_id`` from VARCHAR(64) to TEXT on PostgreSQL.
|
||||
|
||||
The original split-history migration (``a7b8c9d0e1f2``) declared
|
||||
``observation_history.bank_id`` and ``mental_model_history.bank_id`` as
|
||||
``VARCHAR(64)`` on PostgreSQL. But ``memory_units.bank_id`` — the backfill
|
||||
source for observations — is ``TEXT`` (unbounded), as are ``banks``,
|
||||
``documents`` and ``entities``. Any deployment whose ``bank_id`` exceeds 64
|
||||
characters aborts the backfill ``INSERT`` with::
|
||||
|
||||
psycopg2.errors.StringDataRightTruncation: value too long for type
|
||||
character varying(64)
|
||||
|
||||
Because the migration runs in ``lifespan`` startup inside a transaction, the
|
||||
whole migration rolls back and the API never comes up — unrecoverable from the
|
||||
running container. See https://github.com/vectorize-io/hindsight/issues/2106.
|
||||
|
||||
``a7b8c9d0e1f2`` itself has been corrected to create the column as ``TEXT``,
|
||||
which unblocks deployments that *failed* (the migration rolled back, so it
|
||||
re-runs the fixed DDL). This forward migration covers deployments that already
|
||||
*succeeded* with the narrow ``VARCHAR(64)`` column — where editing
|
||||
``a7b8c9d0e1f2`` has no effect because it will not re-run — by widening the
|
||||
column in place. ``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is
|
||||
already ``TEXT`` (fresh installs and re-run failures), so every upgrade path
|
||||
converges on ``TEXT``.
|
||||
|
||||
The history tables are per-tenant (they live in each tenant schema, not
|
||||
``public``), so this runs for every migrated schema via the search-path-aware
|
||||
prefix — unlike the shared-``public`` routines repaired in ``b2d4f6a8c1e3``.
|
||||
|
||||
PostgreSQL only. On Oracle both ``memory_units.bank_id`` and the history
|
||||
``bank_id`` columns are already ``VARCHAR2(256)`` (consistent, never
|
||||
truncates), so the Oracle slot is intentionally absent.
|
||||
|
||||
Revision ID: c3e5a7b9d1f4
|
||||
Revises: c9a1b2d3e4f5
|
||||
Create Date: 2026-06-10
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "c3e5a7b9d1f4"
|
||||
down_revision: str | Sequence[str] | None = "c9a1b2d3e4f5"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}observation_history ALTER COLUMN bank_id TYPE TEXT")
|
||||
op.execute(f"ALTER TABLE {schema}mental_model_history ALTER COLUMN bank_id TYPE TEXT")
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
|
||||
# re-introduce the bug this migration repairs. The column type is owned by
|
||||
# ``a7b8c9d0e1f2``'s lifecycle.
|
||||
pass
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""Add invalidated_memory_units table for curation (edit/invalidate).
|
||||
|
||||
Curation keeps the recall hot-path (``memory_units``) clean by *moving*
|
||||
invalidated facts into a sibling archive table rather than flagging them in
|
||||
place. If a row is in ``memory_units`` it is live; if it is in
|
||||
``invalidated_memory_units`` it has been retired. Recall/consolidation/graph
|
||||
queries never need a state predicate — the rows simply aren't there.
|
||||
|
||||
The archive mirrors ``memory_units`` column-for-column — except ``embedding``,
|
||||
which it never keeps: the archive is cold storage, never a recall surface, and
|
||||
revert recomputes the embedding from the unit's text/dates/entities. Keeping no
|
||||
archive vector also means a later embedding-model switch (which re-dimensions
|
||||
``memory_units``) can't trip a dimension mismatch on the move (#2209). Plus:
|
||||
- ``invalidation_reason`` optional free text recorded on invalidate
|
||||
- ``invalidated_at`` when it was retired
|
||||
- ``entity_ids`` snapshot of the unit's entity associations, so revert
|
||||
can restore them (``unit_entities`` is cascade-deleted
|
||||
when the live row is removed)
|
||||
|
||||
This migration also adds ``edited_at`` to ``memory_units``: set whenever a user
|
||||
edits a memory's fields (text, context, dates, fact_type, entities) via curation.
|
||||
NULL means never manually modified; a non-NULL value answers "has the user ever
|
||||
changed this?" with the time of the last edit (distinct from ``updated_at``,
|
||||
which background operations also bump). It is added to ``memory_units`` *before*
|
||||
the archive is cloned below, so the archive inherits the column and the marker
|
||||
travels with a fact when it is invalidated.
|
||||
|
||||
Revision ID: c9a1b2d3e4f5
|
||||
Revises: b2d4f6a8c1e3
|
||||
Create Date: 2026-06-03
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "c9a1b2d3e4f5"
|
||||
down_revision: str | Sequence[str] | None = "b2d4f6a8c1e3"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
# Add edited_at to the live table FIRST so the archive's LIKE clone below
|
||||
# inherits it (keeps the two tables column-for-column identical for round-trip).
|
||||
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ")
|
||||
# LIKE ... INCLUDING DEFAULTS clones every memory_units column (incl.
|
||||
# edited_at) so an invalidated row can move back verbatim. We deliberately
|
||||
# omit indexes/constraints — the archive is cold storage, not a recall
|
||||
# surface; only the lookups below need indexing.
|
||||
op.execute(
|
||||
f"CREATE TABLE IF NOT EXISTS {schema}invalidated_memory_units (LIKE {schema}memory_units INCLUDING DEFAULTS)"
|
||||
)
|
||||
# ...then drop the inherited embedding: the archive never stores one (revert
|
||||
# recomputes it), so it isn't created here only to be dropped again later by
|
||||
# d4f6a8c2e1b3. That migration still runs as a no-op (DROP ... IF EXISTS) on
|
||||
# fresh DBs and does the real drop on DBs created before this column was removed.
|
||||
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
|
||||
op.execute(
|
||||
f"ALTER TABLE {schema}invalidated_memory_units "
|
||||
f"ADD COLUMN IF NOT EXISTS invalidation_reason TEXT, "
|
||||
f"ADD COLUMN IF NOT EXISTS invalidated_at TIMESTAMPTZ DEFAULT now(), "
|
||||
f"ADD COLUMN IF NOT EXISTS entity_ids UUID[]"
|
||||
)
|
||||
op.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_invalidated_mu_id ON {schema}invalidated_memory_units (id)")
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_invalidated_mu_bank "
|
||||
f"ON {schema}invalidated_memory_units (bank_id, invalidated_at)"
|
||||
)
|
||||
# Deleting a document (or bank) should clear its archived facts too, mirroring
|
||||
# the memory_units → documents cascade.
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invalidated_mu_document_fkey') THEN
|
||||
ALTER TABLE {schema}invalidated_memory_units
|
||||
ADD CONSTRAINT invalidated_mu_document_fkey
|
||||
FOREIGN KEY (document_id, bank_id)
|
||||
REFERENCES {schema}documents(id, bank_id) ON DELETE CASCADE;
|
||||
END IF; END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
# Drops the archive (and its inherited edited_at) wholesale, then removes
|
||||
# edited_at from the live table.
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}invalidated_memory_units")
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS edited_at")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PG-only: Oracle gets the table from the baseline snapshot, matching the
|
||||
# convention used by sibling column/index migrations in this tree.
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
"""Drop the embedding column from the curation archive (invalidated_memory_units).
|
||||
|
||||
The archive is cold storage, never a recall surface, so it has no business
|
||||
keeping an embedding. Earlier curation code copied the live row's embedding into
|
||||
``invalidated_memory_units`` on invalidate; the engine now leaves it out on
|
||||
invalidate and recomputes it on revert, so the column is dead weight.
|
||||
|
||||
Dropping it makes "the archive holds no embedding" a schema-enforced invariant
|
||||
rather than a convention the move queries have to honour, and removes a latent
|
||||
failure mode (#2209): after an embedding-model switch the live tables are
|
||||
re-dimensioned but the archive was not, so a stale old-dimension embedding in
|
||||
the archive tripped a vector-dimension mismatch on the INSERT … SELECT
|
||||
round-trip. With no column at all, there is nothing to mismatch.
|
||||
|
||||
The creation sites no longer add the column (the PG ``LIKE`` clone in
|
||||
c9a1b2d3e4f5 drops it; the Oracle baseline omits it), so on a fresh database
|
||||
this migration is a no-op (DROP ... IF EXISTS / Oracle ORA-00904 swallow). It
|
||||
does the real work on databases created before the column was removed there.
|
||||
|
||||
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
|
||||
table rewrite), so it is cheap even across many tenant schemas. The downgrade
|
||||
re-adds an unconstrained vector column (any dimension) — empty, since the
|
||||
embeddings are intentionally discarded.
|
||||
|
||||
Revision ID: d4f6a8c2e1b3
|
||||
Revises: a1d3f5b7c9e2
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "d4f6a8c2e1b3"
|
||||
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
# Unconstrained `vector` (no dimension) so the re-added column accepts any
|
||||
# model's embeddings; it comes back empty regardless.
|
||||
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS embedding vector")
|
||||
|
||||
|
||||
def _oracle_upgrade() -> None:
|
||||
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
|
||||
# exist) so the migration is idempotent and safe on a fresh schema whose
|
||||
# baseline already omits the column.
|
||||
op.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN embedding';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
IF SQLCODE != -904 THEN RAISE; END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _oracle_downgrade() -> None:
|
||||
# Swallow ORA-01430 (column already exists) for idempotency.
|
||||
op.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (embedding VECTOR)';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
IF SQLCODE != -1430 THEN RAISE; END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
"""Merge two divergent migration heads.
|
||||
|
||||
``d4f6a8c2e1b3`` (drop the curation-archive embedding column) and
|
||||
``2071c7518f88`` (add the memory_links(bank_id, link_type) index) were authored
|
||||
in parallel off the same parent (``a1d3f5b7c9e2``) and merged independently,
|
||||
leaving the DAG with two heads. This is a no-op merge that re-unifies them so
|
||||
``alembic upgrade head`` is unambiguous again (enforced by
|
||||
``tests/test_alembic_dag.py::test_single_head``).
|
||||
|
||||
Revision ID: e1f2a3b4c5d6
|
||||
Revises: d4f6a8c2e1b3, 2071c7518f88
|
||||
Create Date: 2026-06-16
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "e1f2a3b4c5d6"
|
||||
down_revision: str | Sequence[str] | None = ("d4f6a8c2e1b3", "2071c7518f88")
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
# Pure DAG merge — both parents already applied their schema changes.
|
||||
pass
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
@@ -122,6 +122,7 @@ _TABLES: tuple[str, ...] = (
|
||||
text_signals CLOB,
|
||||
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
|
||||
search_vector CLOB,
|
||||
edited_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
CONSTRAINT pk_memory_units PRIMARY KEY (id),
|
||||
@@ -138,6 +139,50 @@ _TABLES: tuple[str, ...] = (
|
||||
PARTITION BY LIST (bank_id) AUTOMATIC
|
||||
(PARTITION p_default VALUES ('__default__'))
|
||||
""",
|
||||
# Cold archive for curation: invalidated facts are MOVED here out of
|
||||
# memory_units so the recall hot-path never sees them. Mirrors memory_units
|
||||
# plus invalidation bookkeeping and an entity-id snapshot for lossless revert.
|
||||
# No `embedding` column: the archive is cold storage and revert recomputes the
|
||||
# embedding, so there is no archive vector to fall out of sync with the live
|
||||
# model's dimension on a model switch (#2209).
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS invalidated_memory_units (
|
||||
id RAW(16) NOT NULL,
|
||||
bank_id VARCHAR2(256) NOT NULL,
|
||||
document_id VARCHAR2(512),
|
||||
chunk_id VARCHAR2(512),
|
||||
text CLOB NOT NULL,
|
||||
context CLOB,
|
||||
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
occurred_start TIMESTAMP WITH TIME ZONE,
|
||||
occurred_end TIMESTAMP WITH TIME ZONE,
|
||||
mentioned_at TIMESTAMP WITH TIME ZONE,
|
||||
fact_type VARCHAR2(64) DEFAULT 'world' NOT NULL,
|
||||
confidence_score BINARY_DOUBLE,
|
||||
access_count NUMBER(10) DEFAULT 0 NOT NULL,
|
||||
consolidated_at TIMESTAMP WITH TIME ZONE,
|
||||
observation_scopes CLOB CONSTRAINT imu_obs_scopes_json CHECK (observation_scopes IS JSON OR observation_scopes IS NULL),
|
||||
tags CLOB DEFAULT '[]' NOT NULL,
|
||||
metadata CLOB DEFAULT '{}' NOT NULL
|
||||
CONSTRAINT imu_metadata_json CHECK (metadata IS JSON),
|
||||
proof_count NUMBER(10) DEFAULT 1,
|
||||
source_memory_ids CLOB,
|
||||
history CLOB DEFAULT '[]'
|
||||
CONSTRAINT imu_history_json CHECK (history IS JSON OR history IS NULL),
|
||||
text_signals CLOB,
|
||||
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
|
||||
search_vector CLOB,
|
||||
edited_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
invalidation_reason CLOB,
|
||||
invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP,
|
||||
entity_ids CLOB CONSTRAINT imu_entity_ids_json CHECK (entity_ids IS JSON OR entity_ids IS NULL),
|
||||
CONSTRAINT pk_invalidated_memory_units PRIMARY KEY (id),
|
||||
CONSTRAINT fk_imu_document FOREIGN KEY (document_id, bank_id)
|
||||
REFERENCES documents(id, bank_id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id RAW(16) DEFAULT SYS_GUID() NOT NULL,
|
||||
|
||||
-2
@@ -16,9 +16,7 @@ retention parameters, retrieval settings, etc.) in Python field name format.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Client-disconnect detection that works behind ``BaseHTTPMiddleware``.
|
||||
|
||||
``Request.is_disconnected()`` is the obvious way to notice an abandoned HTTP
|
||||
request, but it is silently broken once any ``@app.middleware("http")``
|
||||
(Starlette ``BaseHTTPMiddleware``) is installed: that middleware runs the route
|
||||
in a child task behind anyio memory streams, so the ``http.disconnect`` ASGI
|
||||
event never reaches the route's ``Request``. This app has such middlewares, so
|
||||
the recall/reflect cancellation in #2122/#2127 never actually fired in
|
||||
production — the disconnect was never observed.
|
||||
|
||||
This pure-ASGI middleware sits *outside* the ``BaseHTTPMiddleware`` layer, where
|
||||
it still owns the real ``receive`` channel. For the recall and reflect routes it
|
||||
drains ``receive`` in a background task and trips a :class:`CancellationToken`
|
||||
the moment ``http.disconnect`` arrives, stashing the token on the ASGI ``scope``.
|
||||
The route copies that token onto its ``RequestContext`` and the engine checks it
|
||||
at stage boundaries — so abandoned work stops instead of running to completion.
|
||||
|
||||
It only wraps recall/reflect (small JSON bodies); every other request — uploads,
|
||||
MCP streams, etc. — passes straight through untouched, so there is no buffering
|
||||
or latency cost elsewhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Awaitable, Callable, MutableMapping
|
||||
from typing import Any
|
||||
|
||||
from ..cancellation import CancellationToken
|
||||
|
||||
# Key under which the per-request CancellationToken is stored on the ASGI scope.
|
||||
# A dedicated top-level scope key (not scope["state"]) avoids any interaction
|
||||
# with Starlette's per-request state copying.
|
||||
SCOPE_CANCELLATION_TOKEN = "hindsight.cancellation_token"
|
||||
|
||||
_CLIENT_DISCONNECTED_REASON = "client disconnected"
|
||||
|
||||
Scope = MutableMapping[str, Any]
|
||||
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
|
||||
Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
def _should_monitor(path: str) -> bool:
|
||||
"""Only the two long-running, abandon-prone read endpoints need monitoring."""
|
||||
return path.endswith("/memories/recall") or path.endswith("/reflect")
|
||||
|
||||
|
||||
class ClientDisconnectCancellationMiddleware:
|
||||
"""Trip a scope-level CancellationToken when the client disconnects.
|
||||
|
||||
Must be installed *outside* any ``BaseHTTPMiddleware`` so it owns the real
|
||||
ASGI ``receive`` channel.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Callable) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or not _should_monitor(scope.get("path", "")):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
token = CancellationToken()
|
||||
scope[SCOPE_CANCELLATION_TOKEN] = token
|
||||
|
||||
# The downstream app still needs to read the request body, so we cannot
|
||||
# simply consume `receive` ourselves. Instead a single pump task drains
|
||||
# the real channel, forwards every message to a queue the app reads from,
|
||||
# and trips the token the instant `http.disconnect` shows up — which the
|
||||
# app would otherwise never pull once it has finished reading the body.
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async def pump() -> None:
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
token.cancel(_CLIENT_DISCONNECTED_REASON)
|
||||
await queue.put(message)
|
||||
return
|
||||
await queue.put(message)
|
||||
|
||||
async def proxied_receive() -> MutableMapping[str, Any]:
|
||||
return await queue.get()
|
||||
|
||||
pump_task = asyncio.create_task(pump())
|
||||
try:
|
||||
await self.app(scope, proxied_receive, send)
|
||||
finally:
|
||||
pump_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await pump_task
|
||||
|
||||
|
||||
def get_scope_cancellation_token(scope: Scope) -> CancellationToken | None:
|
||||
"""Return the CancellationToken the middleware attached, if any."""
|
||||
return scope.get(SCOPE_CANCELLATION_TOKEN)
|
||||
@@ -10,13 +10,16 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
|
||||
from hindsight_api.cancellation import OperationCancelledError
|
||||
from hindsight_api.engine.audit import (
|
||||
AuditEntry,
|
||||
AuditLogger,
|
||||
@@ -41,13 +44,78 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
from typing import Callable
|
||||
from collections.abc import Iterable
|
||||
from types import UnionType
|
||||
from typing import Callable, Union, get_args, get_origin
|
||||
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
|
||||
|
||||
def _annotation_is_nullable(annotation: Any) -> bool:
|
||||
"""True if the annotation is a Union that includes None (i.e. ``X | None``)."""
|
||||
if get_origin(annotation) in (Union, UnionType):
|
||||
return any(arg is type(None) for arg in get_args(annotation))
|
||||
return False
|
||||
|
||||
|
||||
def _iter_models(annotation: Any) -> Iterable[type[BaseModel]]:
|
||||
"""Yield every Pydantic model referenced by an annotation, recursing through generics."""
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
yield annotation
|
||||
return
|
||||
for arg in get_args(annotation):
|
||||
yield from _iter_models(arg)
|
||||
|
||||
|
||||
def _model_has_required_nullable(model: type[BaseModel], seen: set[type[BaseModel]]) -> bool:
|
||||
"""True if the model (or any nested model) declares a required *and* nullable field.
|
||||
|
||||
Such a field is in the OpenAPI ``required`` set but may serialize to null, so dropping
|
||||
it (via ``exclude_none``) would omit a key that strict generated clients expect to be
|
||||
present. Routes whose response model contains one of these must keep emitting nulls to
|
||||
stay wire-compatible with already-generated clients.
|
||||
"""
|
||||
if model in seen:
|
||||
return False
|
||||
seen.add(model)
|
||||
for field in model.model_fields.values():
|
||||
annotation = field.annotation
|
||||
if field.is_required() and _annotation_is_nullable(annotation):
|
||||
return True
|
||||
for nested in _iter_models(annotation):
|
||||
if _model_has_required_nullable(nested, seen):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _response_model_has_required_nullable(response_model: Any) -> bool:
|
||||
seen: set[type[BaseModel]] = set()
|
||||
return any(_model_has_required_nullable(model, seen) for model in _iter_models(response_model))
|
||||
|
||||
|
||||
class ExcludeNoneRoute(APIRoute):
|
||||
"""Route class that drops null fields from responses, preserving wire compatibility.
|
||||
|
||||
``response_model_exclude_none`` is enabled automatically for every route whose response
|
||||
model has no required-and-nullable field. Routes that *do* have such a field (where an
|
||||
omitted key would break strict clients) are left untouched and keep emitting nulls.
|
||||
An explicit ``response_model_exclude_none`` passed to the route decorator is respected.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
response_model = kwargs.get("response_model")
|
||||
if (
|
||||
not kwargs.get("response_model_exclude_none")
|
||||
and response_model is not None
|
||||
and not _response_model_has_required_nullable(response_model)
|
||||
):
|
||||
kwargs["response_model_exclude_none"] = True
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
|
||||
"""
|
||||
Field wrapper that ensures default_factory values appear in OpenAPI schema.
|
||||
@@ -80,9 +148,14 @@ 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.memory_engine import Budget, _current_schema, _get_tiktoken_encoding
|
||||
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.response_models import (
|
||||
VALID_RECALL_FACT_TYPES,
|
||||
DryRunExtractionResult,
|
||||
MemoryFact,
|
||||
TokenUsage,
|
||||
)
|
||||
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
|
||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
|
||||
@@ -90,6 +163,44 @@ from hindsight_api.models import RequestContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 499 is the de facto reverse-proxy status for "client closed request".
|
||||
_CLIENT_CLOSED_REQUEST_STATUS_CODE = 499
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
async def run_cancellable_on_disconnect(
|
||||
http_request: Request,
|
||||
request_context: RequestContext,
|
||||
coro: Awaitable[_T],
|
||||
*,
|
||||
operation: str,
|
||||
bank_id: str,
|
||||
) -> _T:
|
||||
"""Run an engine coroutine, aborting it with 499 if the client disconnects.
|
||||
|
||||
Shared by the recall and reflect handlers. The actual disconnect detection
|
||||
lives in ``ClientDisconnectCancellationMiddleware`` (a pure-ASGI middleware
|
||||
installed outside the ``BaseHTTPMiddleware`` layer), which attaches a
|
||||
:class:`CancellationToken` to the ASGI scope and trips it on
|
||||
``http.disconnect``. Here we simply hand that token to the engine via
|
||||
``RequestContext`` — the engine checks it at stage/iteration boundaries — and
|
||||
translate the resulting ``OperationCancelledError`` into 499 so abandoned
|
||||
work stops instead of running to completion (issue #2122).
|
||||
|
||||
Note: ``Request.is_disconnected()`` is deliberately NOT used — it silently
|
||||
never fires behind ``BaseHTTPMiddleware``, which is why the original #2127
|
||||
implementation did not actually cancel anything in this app.
|
||||
"""
|
||||
token = get_scope_cancellation_token(http_request.scope)
|
||||
if token is not None:
|
||||
request_context.cancellation = token
|
||||
try:
|
||||
return await coro
|
||||
except OperationCancelledError as e:
|
||||
logger.info(f"[{operation.upper()} CANCELLED] bank={bank_id} reason={e.reason}")
|
||||
raise HTTPException(status_code=_CLIENT_CLOSED_REQUEST_STATUS_CODE, detail=e.reason) from e
|
||||
|
||||
|
||||
class EntityIncludeOptions(BaseModel):
|
||||
"""Options for including entity observations in recall results."""
|
||||
@@ -224,7 +335,7 @@ class RecallResult(BaseModel):
|
||||
|
||||
id: str
|
||||
text: str
|
||||
type: str | None = None # fact type: world, experience, opinion, observation
|
||||
type: str | None = None # fact type: world, experience, observation
|
||||
entities: list[str] | None = None # Entity names mentioned in this fact
|
||||
context: str | None = None
|
||||
occurred_start: str | None = None # ISO format date when the event started
|
||||
@@ -504,13 +615,16 @@ class MemoryItem(BaseModel):
|
||||
return [v]
|
||||
return v
|
||||
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field(
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = Field(
|
||||
default=None,
|
||||
title="ObservationScopes",
|
||||
description=(
|
||||
"How to scope observations during consolidation. "
|
||||
"'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. "
|
||||
"'combined' (default) runs a single pass with all tags together. "
|
||||
"'shared' runs a single pass over one global, untagged scope, so memories consolidate together "
|
||||
"regardless of their tags — useful for deduplicating across volatile per-call provenance tags "
|
||||
"(e.g. per-session ids) while keeping those tags on the source facts. "
|
||||
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
|
||||
),
|
||||
)
|
||||
@@ -811,7 +925,7 @@ class ReflectFact(BaseModel):
|
||||
text: str = Field(
|
||||
description="Fact text. When type='observation', this contains markdown-formatted consolidated knowledge"
|
||||
)
|
||||
type: str | None = None # fact type: world, experience, opinion, observation
|
||||
type: str | None = None # fact type: world, experience, observation
|
||||
context: str | None = None
|
||||
occurred_start: str | None = None
|
||||
occurred_end: str | None = None
|
||||
@@ -1108,7 +1222,14 @@ class CreateBankRequest(BaseModel):
|
||||
)
|
||||
retain_chunk_size: int | None = Field(
|
||||
default=None,
|
||||
description="Maximum token size for each content chunk during retain.",
|
||||
description="Target maximum characters for each content chunk during retain.",
|
||||
)
|
||||
retain_structured_chunk_size: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Maximum characters for a single JSONL line or conversation turn to keep whole during retain. "
|
||||
"Defaults to retain_chunk_size when unset."
|
||||
),
|
||||
)
|
||||
enable_observations: bool | None = Field(
|
||||
default=None,
|
||||
@@ -1148,6 +1269,7 @@ class CreateBankRequest(BaseModel):
|
||||
"retain_extraction_mode",
|
||||
"retain_custom_instructions",
|
||||
"retain_chunk_size",
|
||||
"retain_structured_chunk_size",
|
||||
"enable_observations",
|
||||
"observations_mission",
|
||||
):
|
||||
@@ -1239,6 +1361,33 @@ class GraphDataResponse(BaseModel):
|
||||
limit: int
|
||||
|
||||
|
||||
class ObservationScope(BaseModel):
|
||||
"""A distinct observation scope: an exact tag set plus its observation count."""
|
||||
|
||||
tags: list[str] = Field(
|
||||
description="The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope."
|
||||
)
|
||||
count: int = Field(description="Number of observations that live under this scope")
|
||||
|
||||
|
||||
class ObservationScopesResponse(BaseModel):
|
||||
"""Response model for the observation scopes enumeration endpoint."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"scopes": [
|
||||
{"tags": ["user:alice"], "count": 12},
|
||||
{"tags": ["user:alice", "project:apollo"], "count": 4},
|
||||
{"tags": [], "count": 2},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
scopes: list[ObservationScope] = Field(description="Distinct observation scopes, most populous first")
|
||||
|
||||
|
||||
class ListMemoryUnitsResponse(BaseModel):
|
||||
"""Response model for list memory units endpoint."""
|
||||
|
||||
@@ -1268,6 +1417,32 @@ class ListMemoryUnitsResponse(BaseModel):
|
||||
offset: int
|
||||
|
||||
|
||||
class DryRunExtractRequest(BaseModel):
|
||||
"""Request to run fact extraction ONLY (no resolution/links/embeddings/persistence).
|
||||
|
||||
Every field below the content/context/date is a prompt-affecting override applied just for this
|
||||
call — used to preview what a candidate retain mission (or any extraction setting) would extract,
|
||||
without changing the bank. Unset (null) fields fall back to the bank's resolved config.
|
||||
"""
|
||||
|
||||
content: str = Field(description="Text to extract facts from (e.g. a document or a single chunk).")
|
||||
context: str = Field(default="", description="Optional context about the content.")
|
||||
# Named `timestamp` to match the retain item payload (retain maps timestamp -> event_date internally).
|
||||
timestamp: datetime | None = Field(
|
||||
default=None, description="Reference timestamp for resolving relative times (ISO 8601)."
|
||||
)
|
||||
agent_name: str | None = Field(default=None, description="Narrator override (memory owner) primed in the prompt.")
|
||||
# --- prompt-affecting config overrides (null = use the bank's value) ---
|
||||
retain_mission: str | None = None
|
||||
retain_extraction_mode: str | None = None
|
||||
retain_custom_instructions: str | None = None
|
||||
retain_extract_causal_links: bool | None = None
|
||||
retain_chunk_size: int | None = None
|
||||
entity_labels: list | None = None
|
||||
entities_allow_free_form: bool | None = None
|
||||
llm_output_language: str | None = None
|
||||
|
||||
|
||||
class ListDocumentsResponse(BaseModel):
|
||||
"""Response model for list documents endpoint."""
|
||||
|
||||
@@ -1352,7 +1527,8 @@ class DocumentResponse(BaseModel):
|
||||
|
||||
id: str
|
||||
bank_id: str
|
||||
original_text: str
|
||||
# None when document text storage is disabled (HINDSIGHT_API_STORE_DOCUMENT_TEXT=false).
|
||||
original_text: str | None
|
||||
content_hash: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -1363,6 +1539,12 @@ class DocumentResponse(BaseModel):
|
||||
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
|
||||
document_metadata: dict[str, Any] | None = Field(default=None, description="Document metadata")
|
||||
retain_params: dict[str, Any] | None = Field(default=None, description="Parameters used during retain")
|
||||
observation_scopes: str | list[list[str]] | None = Field(
|
||||
default=None,
|
||||
description="The observation_scopes spec configured at retain time (e.g. 'all_combinations', "
|
||||
"'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set "
|
||||
"(default 'combined' scoping) or for documents retained before this was captured.",
|
||||
)
|
||||
|
||||
|
||||
class UpdateDocumentRequest(BaseModel):
|
||||
@@ -1389,6 +1571,83 @@ class UpdateDocumentResponse(BaseModel):
|
||||
success: bool = True
|
||||
|
||||
|
||||
class UpdateMemoryRequest(BaseModel):
|
||||
"""Request model for curating a single memory unit (edit / invalidate / revert).
|
||||
|
||||
Provide ``text`` to correct the fact, and/or ``state`` to invalidate
|
||||
('invalidated') or revert ('valid') it. ``reason`` is optional free text
|
||||
recorded on the memory. At least one of ``text`` or ``state`` must be set.
|
||||
Only world/experience facts can be curated; observations are derived.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"state": "invalidated",
|
||||
"reason": "superseded: server decommissioned 2026-06-01",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
text: str | None = Field(
|
||||
default=None,
|
||||
description="New fact text. Re-embeds the memory, drops its derived "
|
||||
"observations and links, and triggers re-consolidation.",
|
||||
)
|
||||
context: str | None = Field(
|
||||
default=None,
|
||||
description="New context for the fact. '' clears it; omit to leave unchanged.",
|
||||
)
|
||||
occurred_start: str | None = Field(
|
||||
default=None,
|
||||
description="New occurred-range start (ISO 8601). '' clears it; omit to leave unchanged.",
|
||||
)
|
||||
occurred_end: str | None = Field(
|
||||
default=None,
|
||||
description="New occurred-range end (ISO 8601). '' clears it; omit to leave unchanged.",
|
||||
)
|
||||
fact_type: str | None = Field(
|
||||
default=None,
|
||||
description="Reclassify the fact: 'world' or 'experience'. Omit to leave unchanged.",
|
||||
)
|
||||
entities: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Replace the fact's entities. Names are resolved/find-or-created "
|
||||
"the same way retain does; '[]' detaches all entities. Omit to leave unchanged.",
|
||||
)
|
||||
state: str | None = Field(
|
||||
default=None,
|
||||
description="Curation state: 'invalidated' to soft-retire the memory "
|
||||
"(excluded from recall/consolidation, links and derived observations "
|
||||
"pruned, moved to the archive) or 'valid' to revert. Reversible.",
|
||||
)
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
description="Optional free-text reason recorded when invalidating.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_an_edit(self) -> "UpdateMemoryRequest":
|
||||
if all(
|
||||
v is None
|
||||
for v in (
|
||||
self.text,
|
||||
self.context,
|
||||
self.occurred_start,
|
||||
self.occurred_end,
|
||||
self.fact_type,
|
||||
self.entities,
|
||||
self.state,
|
||||
)
|
||||
):
|
||||
raise ValueError("Provide at least one field to update.")
|
||||
if self.state is not None and self.state not in ("valid", "invalidated"):
|
||||
raise ValueError("state must be 'valid' or 'invalidated'.")
|
||||
if self.fact_type is not None and self.fact_type not in ("world", "experience"):
|
||||
raise ValueError("fact_type must be 'world' or 'experience'.")
|
||||
return self
|
||||
|
||||
|
||||
class DeleteDocumentResponse(BaseModel):
|
||||
"""Response model for delete document endpoint."""
|
||||
|
||||
@@ -1538,6 +1797,51 @@ class BankStatsResponse(BaseModel):
|
||||
total_observations: int = Field(default=0, description="Total number of observations")
|
||||
|
||||
|
||||
class LlmOperationHealth(BaseModel):
|
||||
"""LLM connectivity status for a single operation. Status only — no provider/model/
|
||||
endpoint/error, so the probe never discloses the LLM configuration."""
|
||||
|
||||
operation: Literal["retain", "consolidation", "reflect"] = Field(
|
||||
# Distinct title so the generated clients don't collide this inline enum with the
|
||||
# async-operation "operation" enum (progenitor names Rust types from the title).
|
||||
title="LlmHealthOperation",
|
||||
description="Operation whose LLM was probed",
|
||||
)
|
||||
ok: bool = Field(description="True only when the probe connected successfully")
|
||||
status: Literal["connected", "not_configured", "auth_failed", "unreachable", "timeout"] = Field(
|
||||
# Distinct title — otherwise this inline enum's default title "Status" collides
|
||||
# with the async-operation status enum and breaks the generated Rust client/CLI.
|
||||
title="LlmHealthStatus",
|
||||
description="'connected'; 'not_configured' (provider is 'none'); 'auth_failed' (rejected — "
|
||||
"usually a wrong/expired API key); 'unreachable' (call failed); 'timeout'",
|
||||
)
|
||||
latency_ms: float | None = Field(default=None, description="Round-trip latency of the probe call")
|
||||
|
||||
|
||||
class BankLlmHealthResponse(BaseModel):
|
||||
"""Per-bank LLM connectivity probe across retain/consolidation/reflect. Operations
|
||||
that share a configuration are probed once. Discloses status only — never the
|
||||
provider, model, endpoint, API key, or raw error."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"bank_id": "user123",
|
||||
"operations": [
|
||||
{"operation": "retain", "ok": True, "status": "connected", "latency_ms": 412.0},
|
||||
{"operation": "consolidation", "ok": True, "status": "connected", "latency_ms": 412.0},
|
||||
{"operation": "reflect", "ok": False, "status": "not_configured", "latency_ms": None},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
bank_id: str = Field(description="Bank identifier")
|
||||
operations: list[LlmOperationHealth] = Field(
|
||||
description="Connectivity status per operation (retain, consolidation, reflect)"
|
||||
)
|
||||
|
||||
|
||||
class MemoryTimeseriesBucket(BaseModel):
|
||||
"""One bucket in the memory ingestion time-series."""
|
||||
|
||||
@@ -1830,7 +2134,14 @@ class BankTemplateConfig(BaseModel):
|
||||
retain_custom_instructions: str | None = Field(
|
||||
default=None, description="Custom extraction prompt (when mode='custom')"
|
||||
)
|
||||
retain_chunk_size: int | None = Field(default=None, description="Max token size for each content chunk")
|
||||
retain_chunk_size: int | None = Field(default=None, description="Target max characters for each content chunk")
|
||||
retain_structured_chunk_size: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Max characters for a single JSONL line or conversation turn to keep whole; "
|
||||
"defaults to retain_chunk_size when unset"
|
||||
),
|
||||
)
|
||||
enable_observations: bool | None = Field(default=None, description="Toggle observation consolidation")
|
||||
observations_mission: str | None = Field(default=None, description="Controls what gets synthesised")
|
||||
disposition_skepticism: int | None = Field(default=None, ge=1, le=5, description="Skepticism trait (1-5)")
|
||||
@@ -1866,6 +2177,16 @@ class BankTemplateConfig(BaseModel):
|
||||
max_observations_per_scope: int | None = Field(
|
||||
default=None, description="Max observations to retain per consolidation scope"
|
||||
)
|
||||
observation_scope_limits: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Per-scope overrides of max_observations_per_scope: "
|
||||
'[{"scope": ["run_*", "shared"], "limit": 1}]. Each scope is a list of '
|
||||
"fnmatch tag-globs; a consolidation scope matches under exact cover "
|
||||
"(every tag matched by a glob and every glob matched by a tag). The first "
|
||||
"matching rule wins; unmatched scopes fall back to max_observations_per_scope."
|
||||
),
|
||||
)
|
||||
reflect_source_facts_max_tokens: int | None = Field(
|
||||
default=None, description="Max tokens of source facts per reflect call"
|
||||
)
|
||||
@@ -2413,11 +2734,15 @@ class FeaturesInfo(BaseModel):
|
||||
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
|
||||
worker: bool = Field(description="Whether the background worker is enabled")
|
||||
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
|
||||
bank_llm_health: bool = Field(description="Whether the per-bank LLM connectivity probe is enabled")
|
||||
file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled")
|
||||
document_export_api: bool = Field(description="Whether the document export endpoint is enabled")
|
||||
document_import_api: bool = Field(description="Whether the document import endpoint is enabled")
|
||||
audit_log: bool = Field(description="Whether audit logging is enabled")
|
||||
llm_trace: bool = Field(description="Whether per-bank LLM request tracing is enabled")
|
||||
store_document_text: bool = Field(
|
||||
description="Whether raw source text is persisted. When false, document/chunk source text is not stored."
|
||||
)
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
@@ -2459,7 +2784,7 @@ class CreateWebhookRequest(BaseModel):
|
||||
secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret (optional)")
|
||||
event_types: list[str] = Field(
|
||||
default=["consolidation.completed"],
|
||||
description="List of event types to deliver. Currently supported: 'consolidation.completed'",
|
||||
description="List of event types to deliver. Supported: 'retain.completed', 'consolidation.completed', 'memory_defense.triggered'.",
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this webhook is active")
|
||||
http_config: WebhookHttpConfig = Field(
|
||||
@@ -2569,7 +2894,6 @@ def _make_audited_http(audit_logger_getter: Callable[[], AuditLogger | None]):
|
||||
from datetime import datetime as _dt
|
||||
from datetime import timezone as _tz
|
||||
from functools import wraps
|
||||
from typing import Callable as _Callable
|
||||
|
||||
def audited(action: str, *, request_param: str | None = "request"):
|
||||
"""Decorator that wraps an HTTP handler with audit logging.
|
||||
@@ -2800,6 +3124,10 @@ def create_app(
|
||||
root_path=config.base_path,
|
||||
)
|
||||
|
||||
# Drop null fields from responses (omit `"x": null`) for routes where it's wire-safe.
|
||||
# Must be set before any route is registered so @app.<method> decorators pick it up.
|
||||
app.router.route_class = ExcludeNoneRoute
|
||||
|
||||
# IMPORTANT: Set memory on app.state immediately, don't wait for lifespan
|
||||
# This is required for mounted sub-applications where lifespan may not fire
|
||||
app.state.memory = memory
|
||||
@@ -2913,8 +3241,6 @@ def create_app(
|
||||
# Replace UUIDs and numeric IDs with placeholders
|
||||
import re
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
path = request.url.path
|
||||
# Replace UUIDs
|
||||
path = re.sub(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "/{id}", path)
|
||||
@@ -2944,6 +3270,13 @@ def create_app(
|
||||
app.include_router(root_router)
|
||||
logging.info("HTTP extension root router mounted")
|
||||
|
||||
# Client-disconnect cancellation for recall/reflect. Added LAST so it sits
|
||||
# OUTSIDE the @app.middleware("http") (BaseHTTPMiddleware) layers above —
|
||||
# that placement is mandatory: BaseHTTPMiddleware breaks
|
||||
# Request.is_disconnected(), so the only way to observe an abandoned request
|
||||
# is to own the raw ASGI receive channel from outside it (issue #2122).
|
||||
app.add_middleware(ClientDisconnectCancellationMiddleware)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -3080,11 +3413,13 @@ def _register_routes(app: FastAPI):
|
||||
mcp=config.mcp_enabled,
|
||||
worker=config.worker_enabled,
|
||||
bank_config_api=config.enable_bank_config_api,
|
||||
bank_llm_health=config.enable_bank_llm_health,
|
||||
file_upload_api=config.enable_file_upload_api,
|
||||
document_export_api=config.enable_document_export_api,
|
||||
document_import_api=config.enable_document_import_api,
|
||||
audit_log=config.audit_log_enabled,
|
||||
llm_trace=config.llm_trace_enabled,
|
||||
store_document_text=config.store_document_text,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3106,7 +3441,7 @@ def _register_routes(app: FastAPI):
|
||||
"/v1/default/banks/{bank_id}/graph",
|
||||
response_model=GraphDataResponse,
|
||||
summary="Get memory graph data",
|
||||
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).",
|
||||
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/observation).",
|
||||
operation_id="get_graph",
|
||||
tags=["Memory"],
|
||||
)
|
||||
@@ -3159,6 +3494,8 @@ def _register_routes(app: FastAPI):
|
||||
type: str | None = None,
|
||||
q: str | None = None,
|
||||
consolidation_state: str | None = None,
|
||||
state: str | None = None,
|
||||
document_id: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
@@ -3171,7 +3508,7 @@ def _register_routes(app: FastAPI):
|
||||
|
||||
Args:
|
||||
bank_id: Memory Bank ID (from path)
|
||||
type: Filter by fact type (world, experience, opinion)
|
||||
type: Filter by fact type (world, experience, observation)
|
||||
q: Search query for full-text search (searches text and context)
|
||||
consolidation_state: Filter by consolidation state for source memories
|
||||
(world/experience). One of 'failed', 'pending', or 'done'.
|
||||
@@ -3184,6 +3521,8 @@ def _register_routes(app: FastAPI):
|
||||
fact_type=type,
|
||||
search_query=q,
|
||||
consolidation_state=consolidation_state,
|
||||
state=state,
|
||||
document_id=document_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
request_context=request_context,
|
||||
@@ -3202,6 +3541,75 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/list: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
async def _require_dry_run_enabled() -> None:
|
||||
"""Feature-flag gate for dry-run extraction.
|
||||
|
||||
Declared as a dependency BEFORE ``precheck_for("dry_run_extract")`` so a
|
||||
disabled route returns 404 regardless of tenant/billing state — FastAPI
|
||||
resolves path-operation dependencies in signature order, so this runs
|
||||
first and preserves the original "disabled → 404" contract.
|
||||
"""
|
||||
if not get_config().enable_dry_run_extract:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Dry-run extraction is disabled. Set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true to re-enable.",
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/memories/dry-run-extract",
|
||||
response_model=DryRunExtractionResult,
|
||||
summary="Dry-run fact extraction (preview, no persistence)",
|
||||
description=(
|
||||
"Preview what the retain step would extract from text WITHOUT changing the bank — no "
|
||||
"entity resolution, links, embeddings, or persistence. Returns the candidate facts and "
|
||||
"the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, "
|
||||
"chunk size, …) is overridable in the body to A/B a candidate config against the bank's "
|
||||
"current one. This is a read-only tool: nothing is stored."
|
||||
),
|
||||
operation_id="dry_run_extract_memories",
|
||||
tags=["Memory"],
|
||||
)
|
||||
async def api_dry_run_extract(
|
||||
bank_id: str,
|
||||
body: DryRunExtractRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
_enabled: None = Depends(_require_dry_run_enabled),
|
||||
_precheck: None = Depends(precheck_for("dry_run_extract")),
|
||||
):
|
||||
try:
|
||||
override_fields = (
|
||||
"retain_mission",
|
||||
"retain_extraction_mode",
|
||||
"retain_custom_instructions",
|
||||
"retain_extract_causal_links",
|
||||
"retain_chunk_size",
|
||||
"entity_labels",
|
||||
"entities_allow_free_form",
|
||||
"llm_output_language",
|
||||
)
|
||||
overrides = {f: getattr(body, f) for f in override_fields if getattr(body, f) is not None}
|
||||
return await app.state.memory.extract_dry_run(
|
||||
bank_id,
|
||||
body.content,
|
||||
context=body.context or "",
|
||||
event_date=body.timestamp,
|
||||
overrides=overrides,
|
||||
agent_name=body.agent_name,
|
||||
request_context=request_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/dry-run-extract: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/memories/{memory_id}",
|
||||
summary="Get memory unit",
|
||||
@@ -3237,6 +3645,53 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.patch(
|
||||
"/v1/default/banks/{bank_id}/memories/{memory_id}",
|
||||
summary="Curate memory unit",
|
||||
description="Edit a memory's text and/or change its curation state "
|
||||
"(invalidate / revert). Invalidated memories are excluded from recall, "
|
||||
"consolidation, and graph maintenance but kept for audit (reversible). "
|
||||
"Only world/experience facts can be curated; observations are derived.",
|
||||
operation_id="update_memory",
|
||||
tags=["Memory"],
|
||||
)
|
||||
async def api_update_memory(
|
||||
bank_id: str,
|
||||
memory_id: str,
|
||||
request: UpdateMemoryRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Curate a single memory unit (edit text / invalidate / revert)."""
|
||||
try:
|
||||
data = await app.state.memory.update_memory_unit(
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
text=request.text,
|
||||
context=request.context,
|
||||
occurred_start=request.occurred_start,
|
||||
occurred_end=request.occurred_end,
|
||||
new_fact_type=request.fact_type,
|
||||
entities=request.entities,
|
||||
state=request.state,
|
||||
reason=request.reason,
|
||||
request_context=request_context,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail=f"Memory unit '{memory_id}' not found")
|
||||
return data
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/memories/{memory_id}/history",
|
||||
summary="Get observation history",
|
||||
@@ -3285,6 +3740,7 @@ def _register_routes(app: FastAPI):
|
||||
async def api_recall(
|
||||
bank_id: str,
|
||||
request: RecallRequest,
|
||||
http_request: Request,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
_precheck: None = Depends(precheck_for("recall")),
|
||||
):
|
||||
@@ -3340,25 +3796,34 @@ def _register_routes(app: FastAPI):
|
||||
"recall", bank_id=bank_id, source="api", budget=request.budget.value, max_tokens=request.max_tokens
|
||||
):
|
||||
recall_start = time.time()
|
||||
core_result = await app.state.memory.recall_async(
|
||||
# Cancel the recall if the client disconnects: the engine checks
|
||||
# request_context at each stage boundary and aborts abandoned
|
||||
# work rather than running it to completion (issue #2122).
|
||||
core_result = await run_cancellable_on_disconnect(
|
||||
http_request,
|
||||
request_context,
|
||||
app.state.memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=request.query,
|
||||
budget=request.budget,
|
||||
max_tokens=request.max_tokens,
|
||||
enable_trace=request.trace,
|
||||
fact_type=fact_types,
|
||||
question_date=question_date,
|
||||
include_entities=include_entities,
|
||||
max_entity_tokens=max_entity_tokens,
|
||||
include_chunks=include_chunks,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
include_source_facts=include_source_facts,
|
||||
max_source_facts_tokens=max_source_facts_tokens,
|
||||
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
|
||||
request_context=request_context,
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
tag_groups=request.tag_groups,
|
||||
),
|
||||
operation="recall",
|
||||
bank_id=bank_id,
|
||||
query=request.query,
|
||||
budget=request.budget,
|
||||
max_tokens=request.max_tokens,
|
||||
enable_trace=request.trace,
|
||||
fact_type=fact_types,
|
||||
question_date=question_date,
|
||||
include_entities=include_entities,
|
||||
max_entity_tokens=max_entity_tokens,
|
||||
include_chunks=include_chunks,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
include_source_facts=include_source_facts,
|
||||
max_source_facts_tokens=max_source_facts_tokens,
|
||||
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
|
||||
request_context=request_context,
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
tag_groups=request.tag_groups,
|
||||
)
|
||||
|
||||
# Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
|
||||
@@ -3462,11 +3927,11 @@ def _register_routes(app: FastAPI):
|
||||
"/v1/default/banks/{bank_id}/reflect",
|
||||
response_model=ReflectResponse,
|
||||
summary="Reflect and generate answer",
|
||||
description="Reflect and formulate an answer using bank identity, world facts, and opinions.\n\n"
|
||||
description="Reflect and formulate an answer using bank identity, world facts, observations, and mental models.\n\n"
|
||||
"This endpoint:\n"
|
||||
"1. Retrieves experience (conversations and events)\n"
|
||||
"2. Retrieves world facts relevant to the query\n"
|
||||
"3. Retrieves existing opinions (bank's perspectives)\n"
|
||||
"3. Retrieves observations and mental models (bank's synthesized perspectives)\n"
|
||||
"4. Uses LLM to formulate a contextual answer\n"
|
||||
"5. Returns plain text answer and the facts used",
|
||||
operation_id="reflect",
|
||||
@@ -3476,6 +3941,7 @@ def _register_routes(app: FastAPI):
|
||||
async def api_reflect(
|
||||
bank_id: str,
|
||||
request: ReflectRequest,
|
||||
http_request: Request,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
_precheck: None = Depends(precheck_for("reflect")),
|
||||
):
|
||||
@@ -3489,20 +3955,30 @@ def _register_routes(app: FastAPI):
|
||||
|
||||
# Use the memory system's reflect_async method (record metrics)
|
||||
with metrics.record_operation("reflect", bank_id=bank_id, source="api", budget=request.budget.value):
|
||||
core_result = await app.state.memory.reflect_async(
|
||||
# Cancel the reflect if the client disconnects: the agent loop
|
||||
# checks request_context between iterations and the nested recall
|
||||
# checks at its stage boundaries, so abandoned work stops instead
|
||||
# of running to completion (issue #2122).
|
||||
core_result = await run_cancellable_on_disconnect(
|
||||
http_request,
|
||||
request_context,
|
||||
app.state.memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
budget=request.budget,
|
||||
context=None, # Deprecated, now concatenated with query
|
||||
max_tokens=request.max_tokens,
|
||||
response_schema=request.response_schema,
|
||||
request_context=request_context,
|
||||
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,
|
||||
),
|
||||
operation="reflect",
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
budget=request.budget,
|
||||
context=None, # Deprecated, now concatenated with query
|
||||
max_tokens=request.max_tokens,
|
||||
response_schema=request.response_schema,
|
||||
request_context=request_context,
|
||||
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
|
||||
@@ -3668,6 +4144,45 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in /v1/default/banks/{bank_id}/stats: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/health/llm",
|
||||
response_model=BankLlmHealthResponse,
|
||||
summary="Test the bank's LLM connectivity",
|
||||
description="Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call "
|
||||
"each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' "
|
||||
"instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status "
|
||||
"only — never the provider, model, endpoint, API key, or raw error. Disable with "
|
||||
"HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false.",
|
||||
operation_id="test_bank_llm",
|
||||
tags=["Banks"],
|
||||
)
|
||||
async def api_bank_llm_health(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
|
||||
"""Probe per-bank LLM connectivity."""
|
||||
if not get_config().enable_bank_llm_health:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Bank LLM health check is disabled. Set HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=true to enable.",
|
||||
)
|
||||
try:
|
||||
result = await app.state.memory.check_bank_llm(bank_id, request_context=request_context)
|
||||
return BankLlmHealthResponse(
|
||||
bank_id=result.bank_id,
|
||||
operations=[
|
||||
LlmOperationHealth(operation=op.operation, ok=op.ok, status=op.status, latency_ms=op.latency_ms)
|
||||
for op in result.operations
|
||||
],
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in POST /v1/default/banks/{bank_id}/health/llm: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/stats/memories-timeseries",
|
||||
response_model=MemoriesTimeseriesResponse,
|
||||
@@ -5486,6 +6001,35 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/observations/scopes",
|
||||
response_model=ObservationScopesResponse,
|
||||
summary="List observation scopes",
|
||||
description=(
|
||||
"Enumerate the distinct scopes across a bank's observations. Each observation lives "
|
||||
"under a scope: the exact set of tags it was consolidated with. Returns every distinct "
|
||||
"scope (tag order normalized) with the number of observations in it; the empty tag list "
|
||||
"is the global/untagged scope. Use a returned scope with the graph endpoint "
|
||||
"(tags=<scope> & tags_match=exact) to filter observations to exactly that scope."
|
||||
),
|
||||
operation_id="list_observation_scopes",
|
||||
tags=["Memory"],
|
||||
)
|
||||
async def api_list_observation_scopes(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
|
||||
"""List the distinct observation scopes (exact tag sets) for a bank."""
|
||||
try:
|
||||
return await app.state.memory.list_observation_scopes(bank_id, request_context=request_context)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/observations/scopes: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/consolidation/recover",
|
||||
response_model=RecoverConsolidationResponse,
|
||||
@@ -5627,6 +6171,15 @@ def _register_routes(app: FastAPI):
|
||||
app.state.memory._operation_validator.validate_bank_write(ctx)
|
||||
)
|
||||
|
||||
# Validate Memory Defense policy shape before persisting.
|
||||
if "memory_defense" in request.updates and request.updates["memory_defense"] is not None:
|
||||
from hindsight_api.extensions.memory_defense import parse_policy
|
||||
|
||||
try:
|
||||
parse_policy(request.updates["memory_defense"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"invalid memory_defense policy: {exc}")
|
||||
|
||||
# Update config via config resolver (validates configurable fields and permissions)
|
||||
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
|
||||
|
||||
@@ -6155,7 +6708,21 @@ def _register_routes(app: FastAPI):
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except ValueError as e:
|
||||
# Invalid request parameters (e.g. duplicate document_ids, or
|
||||
# update_mode='append' when document text storage is disabled) are
|
||||
# client errors, not server faults.
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
from dataclasses import asdict
|
||||
|
||||
from hindsight_api.engine.retain.orchestrator import MemoryDefenseAllBlockedError
|
||||
|
||||
if isinstance(e, MemoryDefenseAllBlockedError):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"violations": [asdict(v) for v in e.violations]},
|
||||
)
|
||||
import traceback
|
||||
|
||||
# Create a summary of the input for debugging
|
||||
@@ -6274,7 +6841,6 @@ def _register_routes(app: FastAPI):
|
||||
_validate_parsers(_resolve_parser(request_data.parser), "request-level parser")
|
||||
|
||||
# Prepare file items and calculate total batch size
|
||||
import io
|
||||
|
||||
file_items = []
|
||||
total_batch_size = 0
|
||||
@@ -6353,14 +6919,14 @@ def _register_routes(app: FastAPI):
|
||||
"/v1/default/banks/{bank_id}/memories",
|
||||
response_model=DeleteResponse,
|
||||
summary="Clear memory bank memories",
|
||||
description="Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.",
|
||||
description="Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.",
|
||||
operation_id="clear_bank_memories",
|
||||
tags=["Memory"],
|
||||
)
|
||||
@audited("clear_memories", request_param=None)
|
||||
async def api_clear_bank_memories(
|
||||
bank_id: str,
|
||||
type: str | None = Query(None, description="Optional fact type filter (world, experience, opinion)"),
|
||||
type: str | None = Query(None, description="Optional fact type filter (world, experience, observation)"),
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Clear memories for a memory bank, optionally filtered by type."""
|
||||
|
||||
@@ -113,6 +113,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
"delete_directive",
|
||||
"list_memories",
|
||||
"get_memory",
|
||||
"update_memory",
|
||||
"invalidate_memory",
|
||||
"list_documents",
|
||||
"get_document",
|
||||
"delete_document",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Cooperative cancellation for long-running engine operations.
|
||||
|
||||
Recall runs as a staged pipeline whose heavy stages — graph expansion and
|
||||
cross-encoder reranking — execute in worker threads (``run_in_executor``) that
|
||||
asyncio task cancellation cannot interrupt once they have started. Cancelling
|
||||
the awaiting task only unblocks the ``await``; the thread keeps burning CPU to
|
||||
completion. So rather than rely on task cancellation, callers thread a
|
||||
``CancellationToken`` through ``RequestContext`` and the engine checks it at
|
||||
stage boundaries (``raise_if_cancelled``), bailing out *before* dispatching the
|
||||
next expensive stage.
|
||||
|
||||
This is cooperative by design: it cannot stop a computation already inside a
|
||||
worker thread, but it does stop an abandoned recall from progressing into — or
|
||||
past — that work, which is what starves the instance in issue #2122. The token
|
||||
lives on ``RequestContext``, so any operation that receives one (recall today;
|
||||
reflect/consolidation/MCP later) can adopt the same checkpoints, and any driver
|
||||
(client disconnect today; a deadline tomorrow) can fire it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class OperationCancelledError(Exception):
|
||||
"""Raised at a checkpoint when the operation has been cancelled.
|
||||
|
||||
Carries the ``reason`` set by whoever cancelled (e.g. "client disconnected")
|
||||
so the HTTP layer can translate it into the appropriate status code instead
|
||||
of a generic 500.
|
||||
|
||||
NOTE: this is a plain ``Exception`` on purpose, NOT ``BaseException``. The
|
||||
recall/reflect pipelines have broad ``except Exception`` handlers that would
|
||||
otherwise swallow it — those handlers re-raise ``OperationCancelledError``
|
||||
explicitly (see ``_search_with_retries``) so cancellation propagates to the
|
||||
HTTP layer. A ``BaseException`` would dodge those handlers but also slip past
|
||||
legitimate ``isinstance(result, Exception)`` checks (e.g. the reflect agent's
|
||||
``asyncio.gather(..., return_exceptions=True)`` tool-result handling), which
|
||||
expect every non-tuple result to be an ``Exception``.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str = "operation cancelled") -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class CancellationToken:
|
||||
"""A one-shot, cooperative cancellation signal.
|
||||
|
||||
Cheap to poll (``raise_if_cancelled``) at stage boundaries and awaitable
|
||||
(``wait``) so a driver task can block until cancellation. Safe to share
|
||||
across an engine call tree; polling is a no-op until something cancels, and
|
||||
cancellation is idempotent (the first reason wins).
|
||||
"""
|
||||
|
||||
__slots__ = ("_event", "_reason")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._event = asyncio.Event()
|
||||
self._reason = "operation cancelled"
|
||||
|
||||
def cancel(self, reason: str = "operation cancelled") -> None:
|
||||
"""Signal cancellation. Idempotent; the first reason recorded wins."""
|
||||
if not self._event.is_set():
|
||||
self._reason = reason
|
||||
self._event.set()
|
||||
|
||||
@property
|
||||
def cancelled(self) -> bool:
|
||||
"""Whether cancellation has been signalled."""
|
||||
return self._event.is_set()
|
||||
|
||||
@property
|
||||
def reason(self) -> str:
|
||||
"""The reason recorded by the first ``cancel`` call."""
|
||||
return self._reason
|
||||
|
||||
def raise_if_cancelled(self) -> None:
|
||||
"""Raise ``OperationCancelledError`` if cancellation has been signalled."""
|
||||
if self._event.is_set():
|
||||
raise OperationCancelledError(self._reason)
|
||||
|
||||
async def wait(self) -> None:
|
||||
"""Block until cancellation is signalled."""
|
||||
await self._event.wait()
|
||||
@@ -141,9 +141,11 @@ ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
|
||||
ENV_LLM_REASONING_EFFORT = "HINDSIGHT_API_LLM_REASONING_EFFORT"
|
||||
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
|
||||
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
|
||||
ENV_LLM_BEDROCK_SERVICE_TIER = "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER"
|
||||
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
|
||||
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
|
||||
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
|
||||
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
|
||||
|
||||
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
|
||||
# provider. Each entry is a deployment; the Router tries them in declared order and
|
||||
@@ -156,6 +158,7 @@ ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
|
||||
# Defaults for service tiers
|
||||
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
|
||||
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
|
||||
DEFAULT_LLM_BEDROCK_SERVICE_TIER = None # None (default), "flex", "priority", or "reserved"
|
||||
DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged into OpenAI extra_body
|
||||
DEFAULT_LLM_DEFAULT_HEADERS = (
|
||||
None # None = no extra headers; JSON dict passed as default_headers to provider SDK clients
|
||||
@@ -252,6 +255,7 @@ ENV_EMBEDDINGS_OPENROUTER_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY
|
||||
ENV_EMBEDDINGS_OPENROUTER_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL"
|
||||
ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY"
|
||||
ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL"
|
||||
ENV_RERANKER_OPENROUTER_BASE_URL = "HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL"
|
||||
|
||||
# ZeroEntropy configuration (embeddings)
|
||||
ENV_EMBEDDINGS_ZEROENTROPY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY"
|
||||
@@ -351,6 +355,8 @@ ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
|
||||
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
|
||||
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
|
||||
ENV_ENABLE_BANK_LLM_HEALTH = "HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH"
|
||||
ENV_ENABLE_DRY_RUN_EXTRACT = "HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT"
|
||||
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
|
||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
||||
@@ -391,6 +397,7 @@ ENV_LLM_PROMPT_CACHE_ENABLED = "HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED"
|
||||
# Retain settings
|
||||
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
||||
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
|
||||
ENV_RETAIN_STRUCTURED_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE"
|
||||
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
|
||||
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
|
||||
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
|
||||
@@ -424,6 +431,7 @@ ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH
|
||||
ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE"
|
||||
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
|
||||
ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN"
|
||||
ENV_STORE_DOCUMENT_TEXT = "HINDSIGHT_API_STORE_DOCUMENT_TEXT"
|
||||
|
||||
# Document transfer (export/import documents between banks without re-running the LLM)
|
||||
ENV_ENABLE_DOCUMENT_EXPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API"
|
||||
@@ -447,6 +455,7 @@ ENV_CONSOLIDATION_RECALL_BUDGET = "HINDSIGHT_API_CONSOLIDATION_RECALL_BUDGET"
|
||||
ENV_CONSOLIDATION_MAX_ATTEMPTS = "HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS"
|
||||
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
|
||||
ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE"
|
||||
ENV_OBSERVATION_SCOPE_LIMITS = "HINDSIGHT_API_OBSERVATION_SCOPE_LIMITS"
|
||||
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
|
||||
ENV_OBSERVATION_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES"
|
||||
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
|
||||
@@ -472,6 +481,7 @@ ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
|
||||
|
||||
# Database migrations
|
||||
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
|
||||
ENV_MIGRATION_CONCURRENCY = "HINDSIGHT_API_MIGRATION_CONCURRENCY"
|
||||
|
||||
# Database connection pool
|
||||
ENV_DB_POOL_MIN_SIZE = "HINDSIGHT_API_DB_POOL_MIN_SIZE"
|
||||
@@ -592,6 +602,7 @@ PROVIDER_DEFAULT_MODELS = {
|
||||
"volcano": "doubao-pro-32k",
|
||||
"openrouter": "qwen/qwen3.5-9b",
|
||||
"fireworks": "accounts/fireworks/models/llama-v3p1-8b-instruct",
|
||||
"nous": "deepseek/deepseek-v4-flash",
|
||||
}
|
||||
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
|
||||
# Built-in llama.cpp defaults
|
||||
@@ -615,6 +626,7 @@ DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry expone
|
||||
DEFAULT_LLM_MAX_BACKOFF = 60.0 # Max backoff cap in seconds for retry exponential backoff
|
||||
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
|
||||
DEFAULT_LLM_REASONING_EFFORT = "low"
|
||||
DEFAULT_LLM_SEND_BANK_AS_USER = False # Opt-in: tag provider calls with user=<bank_id>
|
||||
|
||||
# Vertex AI defaults
|
||||
DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
|
||||
@@ -735,6 +747,7 @@ DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
|
||||
# OpenRouter defaults
|
||||
DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b"
|
||||
DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
|
||||
DEFAULT_RERANKER_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/rerank"
|
||||
|
||||
# ZeroEntropy defaults
|
||||
DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1"
|
||||
@@ -792,6 +805,13 @@ DEFAULT_MCP_ENABLED = True
|
||||
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
|
||||
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
|
||||
DEFAULT_ENABLE_BANK_CONFIG_API = True
|
||||
# Dry-run extraction is a preview tool that makes a real LLM call but stores nothing. Enabled by
|
||||
# default; set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=false to remove the endpoint (e.g. to cap
|
||||
# provider cost/abuse on untrusted deployments).
|
||||
DEFAULT_ENABLE_DRY_RUN_EXTRACT = True
|
||||
# The per-bank LLM connectivity probe makes a real provider call, so it's OFF by
|
||||
# default (cost/abuse concerns) and must be explicitly enabled to expose the endpoint.
|
||||
DEFAULT_ENABLE_BANK_LLM_HEALTH = False
|
||||
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
|
||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
|
||||
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
||||
@@ -831,6 +851,7 @@ DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (a
|
||||
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
|
||||
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
|
||||
DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage)
|
||||
DEFAULT_STORE_DOCUMENT_TEXT = True # Persist raw source text in documents.original_text / chunks.chunk_text
|
||||
|
||||
# Document transfer defaults (export/import enabled by default; gated independently)
|
||||
DEFAULT_ENABLE_DOCUMENT_EXPORT_API = True
|
||||
@@ -880,9 +901,16 @@ DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
|
||||
)
|
||||
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
|
||||
DEFAULT_MAX_OBSERVATIONS_PER_SCOPE = -1 # Max observations per tag scope (-1 = unlimited)
|
||||
# Per-scope overrides of the cap above: list of {"scope": [tag-globs], "limit": int}.
|
||||
# First rule whose pattern exact-covers a scope's tags wins; else the default above.
|
||||
DEFAULT_OBSERVATION_SCOPE_LIMITS: list | None = None
|
||||
|
||||
# Database migrations
|
||||
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
|
||||
# Number of tenant schemas to migrate concurrently. Each schema runs in its own
|
||||
# process (Alembic's command.upgrade() is not thread-safe); within a schema the
|
||||
# work is always sequential. 1 = fully sequential (the safe default).
|
||||
DEFAULT_MIGRATION_CONCURRENCY = 1
|
||||
|
||||
# Database connection pool
|
||||
DEFAULT_DB_POOL_MIN_SIZE = 5
|
||||
@@ -1060,6 +1088,63 @@ def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
|
||||
return _parse_positive_int(name, raw, 1)
|
||||
|
||||
|
||||
def _validate_retain_chunking_int(name: str, value: Any) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{name} must be an integer, got {value!r}")
|
||||
if value < 1:
|
||||
raise ValueError(f"{name} must be >= 1, got {value}")
|
||||
return value
|
||||
|
||||
|
||||
def validate_retain_chunking_config(
|
||||
retain_chunk_size: Any,
|
||||
retain_structured_chunk_size: Any,
|
||||
*,
|
||||
retain_chunk_size_name: str = "retain_chunk_size",
|
||||
retain_structured_chunk_size_name: str = "retain_structured_chunk_size",
|
||||
) -> None:
|
||||
"""Validate retain chunking size fields.
|
||||
|
||||
Defaults emit field-style names ("retain_chunk_size") so API/PATCH callers
|
||||
don't have to override them. The startup validator (HindsightConfig.validate)
|
||||
overrides to env-style names ("HINDSIGHT_API_RETAIN_CHUNK_SIZE") for env
|
||||
misconfig errors.
|
||||
"""
|
||||
_validate_retain_chunking_int(retain_chunk_size_name, retain_chunk_size)
|
||||
if retain_structured_chunk_size is None:
|
||||
return
|
||||
_validate_retain_chunking_int(
|
||||
retain_structured_chunk_size_name,
|
||||
retain_structured_chunk_size,
|
||||
)
|
||||
|
||||
|
||||
def validate_retain_completion_token_budget(
|
||||
*,
|
||||
llm_provider: str,
|
||||
retain_max_completion_tokens: int,
|
||||
retain_chunk_size: int,
|
||||
retain_llm_model: str | None = None,
|
||||
llm_model: str | None = None,
|
||||
retain_llm_provider: str | None = None,
|
||||
retain_max_completion_tokens_name: str = "retain_max_completion_tokens",
|
||||
retain_chunk_size_name: str = "retain_chunk_size",
|
||||
) -> None:
|
||||
"""Validate that retain LLM output capacity exceeds the configured chunk size."""
|
||||
if llm_provider == "none" or retain_max_completion_tokens > retain_chunk_size:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Invalid configuration: {retain_max_completion_tokens_name} "
|
||||
f"({retain_max_completion_tokens}) must be greater than "
|
||||
f"{retain_chunk_size_name} ({retain_chunk_size}). "
|
||||
f"\n\nYou have two options to fix this:"
|
||||
f"\n 1. Increase {retain_max_completion_tokens_name} to a value > {retain_chunk_size}"
|
||||
f"\n 2. Use a model that supports at least {retain_max_completion_tokens} output tokens"
|
||||
f"\n (current model: {retain_llm_model or llm_model}, "
|
||||
f"provider: {retain_llm_provider or llm_provider})"
|
||||
)
|
||||
|
||||
|
||||
def _parse_optional_choice(name: str, raw: str | None, allowed: frozenset[str]) -> str | None:
|
||||
"""Parse an optional string env var constrained to a small allowlist."""
|
||||
if raw is None or raw == "":
|
||||
@@ -1212,6 +1297,7 @@ class HindsightConfig:
|
||||
llm_reasoning_effort: str
|
||||
llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto"
|
||||
llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper)
|
||||
llm_bedrock_service_tier: str | None # Bedrock: None (default), "flex", "priority", or "reserved"
|
||||
llm_extra_body: (
|
||||
dict | None
|
||||
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
|
||||
@@ -1219,6 +1305,11 @@ class HindsightConfig:
|
||||
dict | None
|
||||
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
|
||||
llm_strict_schema: bool # Grammar-enforce structured output via the provider's strongest schema mode (see DEFAULT_LLM_STRICT_SCHEMA)
|
||||
# Tags outbound OpenAI-compatible LLM + embedding calls with `user=<bank_id>` for
|
||||
# per-bank cost attribution. Downstream cost gateways (OpenRouter usage accounting,
|
||||
# LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never
|
||||
# overrides a `user` the caller already set.
|
||||
llm_send_bank_as_user: bool
|
||||
|
||||
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
|
||||
# List of deployment dicts evaluated in order with fallback on transient errors.
|
||||
@@ -1350,6 +1441,7 @@ class HindsightConfig:
|
||||
reranker_cohere_timeout: float
|
||||
reranker_openrouter_api_key: str | None
|
||||
reranker_openrouter_model: str
|
||||
reranker_openrouter_base_url: str
|
||||
reranker_openrouter_timeout: float
|
||||
reranker_litellm_api_base: str
|
||||
reranker_litellm_api_key: str | None
|
||||
@@ -1387,6 +1479,8 @@ class HindsightConfig:
|
||||
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
|
||||
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
|
||||
enable_bank_config_api: bool
|
||||
enable_bank_llm_health: bool
|
||||
enable_dry_run_extract: bool
|
||||
# Default bank template (static, server-level only). When set, the manifest is applied
|
||||
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
|
||||
default_bank_template: dict | None
|
||||
@@ -1405,6 +1499,7 @@ class HindsightConfig:
|
||||
# Retain settings
|
||||
retain_max_completion_tokens: int
|
||||
retain_chunk_size: int
|
||||
retain_structured_chunk_size: int | None
|
||||
retain_extract_causal_links: bool
|
||||
retain_extraction_mode: str
|
||||
retain_mission: str | None
|
||||
@@ -1439,6 +1534,7 @@ class HindsightConfig:
|
||||
file_conversion_max_batch_size: int # Max files per request
|
||||
enable_file_upload_api: bool
|
||||
file_delete_after_retain: bool
|
||||
store_document_text: bool # When False, store NULL original_text / empty chunk_text
|
||||
enable_document_export_api: bool
|
||||
enable_document_import_api: bool
|
||||
|
||||
@@ -1462,6 +1558,10 @@ class HindsightConfig:
|
||||
consolidation_max_attempts: int
|
||||
observations_mission: str | None
|
||||
max_observations_per_scope: int
|
||||
# Per-scope observation caps overriding max_observations_per_scope.
|
||||
# Raw JSON shape: [{"scope": ["run_*", "shared"], "limit": 1}, ...]
|
||||
# (validated/applied in engine.consolidation.consolidator._effective_scope_limit)
|
||||
observation_scope_limits: list | None
|
||||
|
||||
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
|
||||
# List of label group dicts: [{key, description, type, optional, values: [{value, description}]}]
|
||||
@@ -1470,6 +1570,10 @@ class HindsightConfig:
|
||||
# When False: only label entities are extracted (or no entities at all if no labels configured)
|
||||
entities_allow_free_form: bool
|
||||
|
||||
# Memory Defense policy (dict matching DefensePolicy schema — validated on write)
|
||||
# None = Memory Defense disabled / not configured for this bank
|
||||
memory_defense: dict | None
|
||||
|
||||
# Reflect agent settings
|
||||
reflect_mission: str | None
|
||||
reflect_source_facts_max_tokens: int
|
||||
@@ -1504,6 +1608,7 @@ class HindsightConfig:
|
||||
|
||||
# Database migrations
|
||||
run_migrations_on_startup: bool
|
||||
migration_concurrency: int
|
||||
|
||||
# Database connection pool
|
||||
db_pool_min_size: int
|
||||
@@ -1594,6 +1699,7 @@ class HindsightConfig:
|
||||
"embeddings_tei_base_url",
|
||||
"reranker_tei_base_url",
|
||||
"reranker_cohere_base_url",
|
||||
"reranker_openrouter_base_url",
|
||||
"embeddings_zeroentropy_base_url",
|
||||
"reranker_zeroentropy_base_url",
|
||||
"reranker_siliconflow_base_url",
|
||||
@@ -1622,6 +1728,7 @@ class HindsightConfig:
|
||||
"mcp_enabled_tools",
|
||||
# Retention settings (behavioral)
|
||||
"retain_chunk_size",
|
||||
"retain_structured_chunk_size",
|
||||
"retain_extraction_mode",
|
||||
"retain_mission",
|
||||
"retain_custom_instructions",
|
||||
@@ -1641,6 +1748,7 @@ class HindsightConfig:
|
||||
"consolidation_source_facts_max_tokens_per_observation",
|
||||
"observations_mission",
|
||||
"max_observations_per_scope",
|
||||
"observation_scope_limits",
|
||||
# Reflect settings
|
||||
"reflect_mission",
|
||||
"reflect_source_facts_max_tokens",
|
||||
@@ -1664,6 +1772,8 @@ class HindsightConfig:
|
||||
"disposition_empathy",
|
||||
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
|
||||
"llm_gemini_safety_settings",
|
||||
# Memory Defense policy (validated against DefensePolicy schema on write)
|
||||
"memory_defense",
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -1759,6 +1869,16 @@ class HindsightConfig:
|
||||
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
|
||||
)
|
||||
|
||||
# Validate bedrock_service_tier
|
||||
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
|
||||
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
|
||||
raise ValueError(
|
||||
f"Invalid HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER: "
|
||||
f"{self.llm_bedrock_service_tier!r}. Must be one of: "
|
||||
f"{', '.join(t for t in valid_bedrock_tiers if t is not None)}. "
|
||||
f"Note: 'standard' is not a valid Bedrock service tier -- use unset for default tier."
|
||||
)
|
||||
|
||||
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
|
||||
if self.llm_provider == "none":
|
||||
self.retain_extraction_mode = "chunks"
|
||||
@@ -1768,20 +1888,23 @@ class HindsightConfig:
|
||||
"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
|
||||
# (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 "
|
||||
f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). "
|
||||
f"\n\nYou have two options to fix this:"
|
||||
f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}"
|
||||
f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens"
|
||||
f"\n (current model: {self.retain_llm_model or self.llm_model}, "
|
||||
f"provider: {self.retain_llm_provider or self.llm_provider})"
|
||||
)
|
||||
validate_retain_chunking_config(
|
||||
self.retain_chunk_size,
|
||||
self.retain_structured_chunk_size,
|
||||
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
|
||||
retain_structured_chunk_size_name="HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE",
|
||||
)
|
||||
|
||||
validate_retain_completion_token_budget(
|
||||
llm_provider=self.llm_provider,
|
||||
retain_max_completion_tokens=self.retain_max_completion_tokens,
|
||||
retain_chunk_size=self.retain_chunk_size,
|
||||
retain_llm_model=self.retain_llm_model,
|
||||
llm_model=self.llm_model,
|
||||
retain_llm_provider=self.retain_llm_provider,
|
||||
retain_max_completion_tokens_name="HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
|
||||
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
|
||||
)
|
||||
|
||||
# Warn if local ML dependencies are missing when configured.
|
||||
# Don't hard-fail here — the actual ImportError fires at model init time
|
||||
@@ -1872,9 +1995,12 @@ class HindsightConfig:
|
||||
llm_reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
|
||||
llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
|
||||
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
|
||||
llm_bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
|
||||
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
|
||||
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
|
||||
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
|
||||
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
|
||||
in ("true", "1"),
|
||||
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
|
||||
# Vertex AI
|
||||
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
|
||||
@@ -2154,6 +2280,9 @@ class HindsightConfig:
|
||||
or os.getenv(ENV_OPENROUTER_API_KEY)
|
||||
or os.getenv(ENV_LLM_API_KEY),
|
||||
reranker_openrouter_model=os.getenv(ENV_RERANKER_OPENROUTER_MODEL, DEFAULT_RERANKER_OPENROUTER_MODEL),
|
||||
reranker_openrouter_base_url=os.getenv(
|
||||
ENV_RERANKER_OPENROUTER_BASE_URL, DEFAULT_RERANKER_OPENROUTER_BASE_URL
|
||||
),
|
||||
reranker_openrouter_timeout=float(
|
||||
os.getenv(ENV_RERANKER_OPENROUTER_TIMEOUT, str(DEFAULT_RERANKER_OPENROUTER_TIMEOUT))
|
||||
),
|
||||
@@ -2216,8 +2345,12 @@ class HindsightConfig:
|
||||
if os.getenv(ENV_MCP_ENABLED_TOOLS)
|
||||
else DEFAULT_MCP_ENABLED_TOOLS,
|
||||
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
|
||||
enable_bank_llm_health=os.getenv(ENV_ENABLE_BANK_LLM_HEALTH, str(DEFAULT_ENABLE_BANK_LLM_HEALTH)).lower()
|
||||
== "true",
|
||||
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
|
||||
== "true",
|
||||
enable_dry_run_extract=os.getenv(ENV_ENABLE_DRY_RUN_EXTRACT, str(DEFAULT_ENABLE_DRY_RUN_EXTRACT)).lower()
|
||||
== "true",
|
||||
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
@@ -2247,6 +2380,10 @@ class HindsightConfig:
|
||||
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
|
||||
),
|
||||
retain_chunk_size=int(os.getenv(ENV_RETAIN_CHUNK_SIZE, str(DEFAULT_RETAIN_CHUNK_SIZE))),
|
||||
retain_structured_chunk_size=_parse_optional_positive_int(
|
||||
ENV_RETAIN_STRUCTURED_CHUNK_SIZE,
|
||||
os.getenv(ENV_RETAIN_STRUCTURED_CHUNK_SIZE),
|
||||
),
|
||||
retain_extract_causal_links=os.getenv(
|
||||
ENV_RETAIN_EXTRACT_CAUSAL_LINKS, str(DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS)
|
||||
).lower()
|
||||
@@ -2302,6 +2439,7 @@ class HindsightConfig:
|
||||
ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN)
|
||||
).lower()
|
||||
== "true",
|
||||
store_document_text=os.getenv(ENV_STORE_DOCUMENT_TEXT, str(DEFAULT_STORE_DOCUMENT_TEXT)).lower() == "true",
|
||||
enable_document_export_api=os.getenv(
|
||||
ENV_ENABLE_DOCUMENT_EXPORT_API, str(DEFAULT_ENABLE_DOCUMENT_EXPORT_API)
|
||||
).lower()
|
||||
@@ -2385,10 +2523,14 @@ class HindsightConfig:
|
||||
max_observations_per_scope=int(
|
||||
os.getenv(ENV_MAX_OBSERVATIONS_PER_SCOPE, str(DEFAULT_MAX_OBSERVATIONS_PER_SCOPE))
|
||||
),
|
||||
observation_scope_limits=json.loads(os.getenv(ENV_OBSERVATION_SCOPE_LIMITS, "null"))
|
||||
or DEFAULT_OBSERVATION_SCOPE_LIMITS,
|
||||
entity_labels=None,
|
||||
entities_allow_free_form=True,
|
||||
memory_defense=None,
|
||||
# Database migrations
|
||||
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
|
||||
migration_concurrency=int(os.getenv(ENV_MIGRATION_CONCURRENCY, str(DEFAULT_MIGRATION_CONCURRENCY))),
|
||||
# Database connection pool
|
||||
db_pool_min_size=int(os.getenv(ENV_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))),
|
||||
db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
|
||||
|
||||
@@ -18,6 +18,8 @@ from hindsight_api.config import (
|
||||
HindsightConfig,
|
||||
_get_raw_config,
|
||||
normalize_config_dict,
|
||||
validate_retain_chunking_config,
|
||||
validate_retain_completion_token_budget,
|
||||
)
|
||||
from hindsight_api.engine.memory_engine import fq_table
|
||||
from hindsight_api.extensions.tenant import TenantExtension
|
||||
@@ -29,6 +31,35 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies: Any) -> None:
|
||||
"""Validate retain strategy chunking with the same semantics as apply_strategy()."""
|
||||
if not isinstance(strategies, dict):
|
||||
return
|
||||
configurable = HindsightConfig.get_configurable_fields()
|
||||
for strategy_name, overrides in strategies.items():
|
||||
if not isinstance(overrides, dict):
|
||||
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
|
||||
filtered = {k: v for k, v in overrides.items() if k in configurable}
|
||||
if not filtered:
|
||||
continue
|
||||
try:
|
||||
resolved = replace(base_config, **filtered)
|
||||
validate_retain_chunking_config(
|
||||
resolved.retain_chunk_size,
|
||||
resolved.retain_structured_chunk_size,
|
||||
)
|
||||
validate_retain_completion_token_budget(
|
||||
llm_provider=resolved.llm_provider,
|
||||
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
|
||||
retain_chunk_size=resolved.retain_chunk_size,
|
||||
retain_llm_model=resolved.retain_llm_model,
|
||||
llm_model=resolved.llm_model,
|
||||
retain_llm_provider=resolved.retain_llm_provider,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
|
||||
|
||||
|
||||
class ConfigResolver:
|
||||
"""Resolves hierarchical configuration with tenant/bank overrides."""
|
||||
|
||||
@@ -46,6 +77,26 @@ class ConfigResolver:
|
||||
self._configurable_fields = HindsightConfig.get_configurable_fields()
|
||||
self._credential_fields = HindsightConfig.get_credential_fields()
|
||||
|
||||
async def _resolve_parent_config_dict(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
|
||||
"""Resolve global + tenant config before bank-level overrides."""
|
||||
config_dict = asdict(self._global_config)
|
||||
|
||||
if self.tenant_extension and context:
|
||||
try:
|
||||
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
|
||||
if tenant_overrides:
|
||||
# Normalize keys and filter to configurable fields only
|
||||
normalized_tenant = normalize_config_dict(tenant_overrides)
|
||||
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
|
||||
config_dict.update(configurable_tenant)
|
||||
logger.debug(
|
||||
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
|
||||
|
||||
return config_dict
|
||||
|
||||
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
|
||||
"""
|
||||
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
|
||||
@@ -65,23 +116,7 @@ class ConfigResolver:
|
||||
Returns:
|
||||
Complete HindsightConfig with hierarchical overrides applied
|
||||
"""
|
||||
# Start with global config (all fields)
|
||||
config_dict = asdict(self._global_config)
|
||||
|
||||
# Load tenant config overrides (if tenant extension available)
|
||||
if self.tenant_extension and context:
|
||||
try:
|
||||
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
|
||||
if tenant_overrides:
|
||||
# Normalize keys and filter to configurable fields only
|
||||
normalized_tenant = normalize_config_dict(tenant_overrides)
|
||||
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
|
||||
config_dict.update(configurable_tenant)
|
||||
logger.debug(
|
||||
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
|
||||
config_dict = await self._resolve_parent_config_dict(bank_id, context)
|
||||
|
||||
# Load bank config overrides
|
||||
bank_overrides = await self._load_bank_config(bank_id)
|
||||
@@ -92,6 +127,10 @@ class ConfigResolver:
|
||||
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
|
||||
# Create a new config instance by copying the global config and updating fields
|
||||
resolved_config = HindsightConfig(**config_dict)
|
||||
validate_retain_chunking_config(
|
||||
resolved_config.retain_chunk_size,
|
||||
resolved_config.retain_structured_chunk_size,
|
||||
)
|
||||
return resolved_config
|
||||
|
||||
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
|
||||
@@ -266,6 +305,29 @@ class ConfigResolver:
|
||||
# Validate recall budget fields
|
||||
_validate_recall_budget_updates(normalized_updates)
|
||||
|
||||
chunking_fields_updated = (
|
||||
"retain_chunk_size" in normalized_updates
|
||||
or "retain_structured_chunk_size" in normalized_updates
|
||||
or "retain_strategies" in normalized_updates
|
||||
)
|
||||
if chunking_fields_updated:
|
||||
config_dict = await self._resolve_parent_config_dict(bank_id, context)
|
||||
active_bank_overrides = await self._load_bank_config(bank_id)
|
||||
for key, value in normalized_updates.items():
|
||||
if key not in self._configurable_fields:
|
||||
continue
|
||||
if value is None:
|
||||
active_bank_overrides.pop(key, None)
|
||||
else:
|
||||
active_bank_overrides[key] = value
|
||||
config_dict.update(active_bank_overrides)
|
||||
base_config = HindsightConfig(**config_dict)
|
||||
validate_retain_chunking_config(
|
||||
base_config.retain_chunk_size,
|
||||
base_config.retain_structured_chunk_size,
|
||||
)
|
||||
_validate_retain_strategy_chunking(base_config, base_config.retain_strategies)
|
||||
|
||||
# Persist the override. Banks are created lazily (on first retain), so a
|
||||
# PATCH that precedes any ingestion would otherwise UPDATE zero rows and
|
||||
# silently no-op while returning 200. Ensure the bank row exists first
|
||||
@@ -364,7 +426,8 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
|
||||
A strategy is a named set of hierarchical field overrides stored in
|
||||
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
|
||||
overridden, including retain_extraction_mode, retain_chunk_size,
|
||||
entity_labels, entities_allow_free_form, etc.
|
||||
retain_structured_chunk_size, entity_labels,
|
||||
entities_allow_free_form, etc.
|
||||
|
||||
Unknown strategy names log a warning and return config unchanged.
|
||||
Unknown or non-hierarchical fields in the strategy are silently ignored.
|
||||
@@ -386,4 +449,17 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
|
||||
return config
|
||||
|
||||
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
|
||||
return replace(config, **filtered)
|
||||
resolved = replace(config, **filtered)
|
||||
validate_retain_chunking_config(
|
||||
resolved.retain_chunk_size,
|
||||
resolved.retain_structured_chunk_size,
|
||||
)
|
||||
validate_retain_completion_token_budget(
|
||||
llm_provider=resolved.llm_provider,
|
||||
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
|
||||
retain_chunk_size=resolved.retain_chunk_size,
|
||||
retain_llm_model=resolved.retain_llm_model,
|
||||
llm_model=resolved.llm_model,
|
||||
retain_llm_provider=resolved.retain_llm_provider,
|
||||
)
|
||||
return resolved
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Per-bank provider cost attribution via the OpenAI ``user`` field.
|
||||
|
||||
Shared by the OpenAI-compatible LLM path and the OpenAI embeddings path so both
|
||||
tag outbound requests identically. Opt-in via ``HINDSIGHT_API_LLM_SEND_BANK_AS_USER``;
|
||||
downstream cost gateways (OpenRouter usage accounting, LiteLLM, Helicone) key spend
|
||||
on the OpenAI ``user`` field.
|
||||
|
||||
Note: when enabled, the bank id is transmitted to the upstream provider as the
|
||||
end-user identifier. Banks that are themselves end-user identifiers are therefore
|
||||
forwarded to the provider — which is exactly what the OpenAI ``user`` field is for,
|
||||
but operators should opt in with that in mind.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def apply_bank_attribution(request: dict[str, Any]) -> None:
|
||||
"""Tag ``request`` with ``user=<bank_id>`` for per-bank cost attribution.
|
||||
|
||||
Mutates ``request`` in place. No-op when the flag is off, no bank is in context,
|
||||
or the caller already set ``user`` — we never override an explicit value.
|
||||
"""
|
||||
if "user" in request:
|
||||
return
|
||||
# Lazy imports: memory_engine imports the embeddings/provider modules that call
|
||||
# this, so a top-level import of memory_engine here would be circular.
|
||||
from ..config import get_config
|
||||
from .memory_engine import get_current_bank_id
|
||||
|
||||
if not get_config().llm_send_bank_as_user:
|
||||
return
|
||||
bank_id = get_current_bank_id()
|
||||
if bank_id:
|
||||
request["user"] = bank_id
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ from collections import defaultdict
|
||||
from contextlib import AsyncExitStack
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from fnmatch import fnmatchcase
|
||||
from itertools import combinations
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
@@ -334,7 +335,15 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
|
||||
|
||||
Returns ``None`` for the default ``combined``-mode single pass (caller uses
|
||||
the memory's own tags). Returns a list[list[str]] when the memory requested
|
||||
multi-pass scoping (``per_tag``, ``all_combinations``, or an explicit list).
|
||||
multi-pass scoping (``per_tag``, ``all_combinations``, ``shared``, or an
|
||||
explicit list).
|
||||
|
||||
``shared`` resolves to ``[[]]`` — a single pass over the empty (untagged)
|
||||
scope. The created observation carries no tags and recall/dedup match it with
|
||||
``tags_match="any"``, so every memory consolidates into one shared observation
|
||||
regardless of its own tags. Use it to deduplicate across volatile per-call
|
||||
provenance tags (e.g. per-session ids) without dropping those tags from the
|
||||
source facts.
|
||||
"""
|
||||
parsed = _parse_observation_scopes(memory)
|
||||
tags = list(memory.get("tags") or [])
|
||||
@@ -345,6 +354,8 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
|
||||
if not tags:
|
||||
return None
|
||||
return [list(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
|
||||
if parsed == "shared":
|
||||
return [[]]
|
||||
if parsed == "combined" or parsed is None:
|
||||
return None
|
||||
return parsed # explicit list[list[str]]
|
||||
@@ -361,6 +372,7 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
|
||||
- ``combined`` / ``None`` -> ``[frozenset(memory.tags)]``
|
||||
- ``per_tag`` -> ``[frozenset({t}) for t in memory.tags]``
|
||||
- ``all_combinations`` -> one frozenset per nonempty subset of tags
|
||||
- ``shared`` -> ``[frozenset()]`` (the single untagged scope)
|
||||
- explicit ``list[list[str]]`` -> one frozenset per declared scope
|
||||
|
||||
Empty-tag memories collapse to a single ``frozenset()`` in all modes so they
|
||||
@@ -375,6 +387,8 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
|
||||
if not tags:
|
||||
return [frozenset()]
|
||||
return [frozenset(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
|
||||
if parsed == "shared":
|
||||
return [frozenset()]
|
||||
if parsed == "combined" or parsed is None:
|
||||
return [frozenset(tags)]
|
||||
return [frozenset(s) for s in parsed] # explicit list[list[str]]
|
||||
@@ -523,6 +537,86 @@ async def _count_observations_for_scope(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ScopeLimitRule:
|
||||
"""One ``observation_scope_limits`` rule: a scope pattern -> an observation cap.
|
||||
|
||||
``globs`` is a tuple of fnmatch tag-globs describing one consolidation scope.
|
||||
A concrete scope (the set of ``fact_tags`` for a consolidation pass) matches
|
||||
under *exact cover*: every tag is matched by some glob AND every glob matches
|
||||
some tag. So ``["shared"]`` matches the scope ``{shared}`` but not
|
||||
``{run_1, shared}``, and ``["run_*", "shared"]`` matches ``{run_1, shared}``
|
||||
but not ``{shared}``.
|
||||
|
||||
``limit`` is the cap applied to matching scopes (-1 = unlimited, 0 = no new
|
||||
observations, >0 = hard cap), mirroring ``max_observations_per_scope``.
|
||||
"""
|
||||
|
||||
globs: tuple[str, ...]
|
||||
limit: int
|
||||
|
||||
|
||||
def _parse_scope_limit_rules(raw: Any) -> list[_ScopeLimitRule]:
|
||||
"""Parse the raw ``observation_scope_limits`` config into ordered rules.
|
||||
|
||||
The config round-trips as JSON through env and the bank-config API, so this
|
||||
is defensive: malformed entries are skipped rather than raising, and list
|
||||
order is preserved (first match wins in :func:`_effective_scope_limit`).
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
rules: list[_ScopeLimitRule] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
scope = entry.get("scope")
|
||||
limit = entry.get("limit")
|
||||
if not isinstance(scope, list) or not scope:
|
||||
continue
|
||||
if not all(isinstance(g, str) and g for g in scope):
|
||||
continue
|
||||
# bool is an int subclass — reject True/False masquerading as a limit.
|
||||
if not isinstance(limit, int) or isinstance(limit, bool):
|
||||
continue
|
||||
rules.append(_ScopeLimitRule(globs=tuple(scope), limit=limit))
|
||||
return rules
|
||||
|
||||
|
||||
def _scope_matches_globs(globs: tuple[str, ...], tags: list[str]) -> bool:
|
||||
"""Exact-cover match between a scope pattern and a concrete tag set.
|
||||
|
||||
True iff every tag is covered by at least one glob AND every glob covers at
|
||||
least one tag (no uncovered tags, no vacuous globs). Untagged scopes never
|
||||
match, so a scope limit never applies to untagged observations (consistent
|
||||
with the ``and fact_tags`` guard at the call site). Matching is
|
||||
case-sensitive (``fnmatchcase``) for deterministic cross-platform behaviour.
|
||||
"""
|
||||
tagset = set(tags)
|
||||
if not tagset:
|
||||
return False
|
||||
if not all(any(fnmatchcase(t, g) for g in globs) for t in tagset):
|
||||
return False
|
||||
if not all(any(fnmatchcase(t, g) for t in tagset) for g in globs):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _effective_scope_limit(config: Any, fact_tags: list[str]) -> int:
|
||||
"""Resolve the observation cap for one concrete consolidation scope.
|
||||
|
||||
The first rule in ``observation_scope_limits`` whose pattern exact-covers
|
||||
``fact_tags`` wins; otherwise falls back to the bank-wide
|
||||
``max_observations_per_scope``. Wildcards live only here, matched against the
|
||||
already-resolved concrete tags — the SQL count stays exact and indexed.
|
||||
"""
|
||||
if config is None:
|
||||
return -1
|
||||
for rule in _parse_scope_limit_rules(getattr(config, "observation_scope_limits", None)):
|
||||
if _scope_matches_globs(rule.globs, fact_tags):
|
||||
return rule.limit
|
||||
return config.max_observations_per_scope
|
||||
|
||||
|
||||
def _build_response_model(max_creates: int | None = None) -> type[_ConsolidationBatchResponse]:
|
||||
"""Build a response model, optionally constraining max creates via JSON schema."""
|
||||
if max_creates is None or max_creates < 0:
|
||||
@@ -1403,11 +1497,15 @@ async def _process_memory_batch(
|
||||
# All memories in the batch share the same tag set (enforced by batching)
|
||||
fact_tags = memories[0].get("tags") or [] if memories else []
|
||||
|
||||
# 2b. Compute remaining observation slots for this scope (if limit configured)
|
||||
max_obs = config.max_observations_per_scope if config is not None else -1
|
||||
# 2b. Compute remaining observation slots for this scope (if limit configured).
|
||||
# The cap is resolved per-scope: an observation_scope_limits rule may override
|
||||
# the bank-wide max_observations_per_scope for scopes matching its tag pattern.
|
||||
max_obs = _effective_scope_limit(config, fact_tags)
|
||||
remaining_observation_slots: int | None = None
|
||||
if max_obs > 0 and fact_tags:
|
||||
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags)
|
||||
if max_obs >= 0 and fact_tags:
|
||||
# max_obs == 0 means "no new observations": there are no slots regardless
|
||||
# of the current count, so skip the count query for that case.
|
||||
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags) if max_obs > 0 else 0
|
||||
remaining_observation_slots = max(max_obs - current_count, 0)
|
||||
if remaining_observation_slots == 0:
|
||||
logger.info(
|
||||
@@ -2045,7 +2143,7 @@ async def _consolidate_batch_with_llm(
|
||||
|
||||
# Build capacity note for the prompt when observation limit is configured
|
||||
observation_capacity_note: str | None = None
|
||||
if remaining_observation_slots is not None and max_observations_per_scope > 0:
|
||||
if remaining_observation_slots is not None and max_observations_per_scope >= 0:
|
||||
if remaining_observation_slots == 0:
|
||||
observation_capacity_note = (
|
||||
f"OBSERVATION LIMIT REACHED ({max_observations_per_scope}/{max_observations_per_scope}). "
|
||||
|
||||
@@ -27,34 +27,21 @@ from ..config import (
|
||||
DEFAULT_RERANKER_LITELLM_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
|
||||
DEFAULT_RERANKER_PROVIDER,
|
||||
DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
|
||||
DEFAULT_RERANKER_SILICONFLOW_MODEL,
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE,
|
||||
DEFAULT_RERANKER_TEI_HTTP_TIMEOUT,
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
|
||||
DEFAULT_ZEROENTROPY_BASE_URL,
|
||||
ENV_RERANKER_ALIBABA_API_KEY,
|
||||
ENV_RERANKER_COHERE_API_KEY,
|
||||
ENV_RERANKER_COHERE_MODEL,
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_GOOGLE_PROJECT_ID,
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU,
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
ENV_RERANKER_LOCAL_MODEL,
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
|
||||
ENV_RERANKER_PROVIDER,
|
||||
ENV_RERANKER_SILICONFLOW_API_KEY,
|
||||
ENV_RERANKER_TEI_BATCH_SIZE,
|
||||
ENV_RERANKER_TEI_HTTP_TIMEOUT,
|
||||
ENV_RERANKER_TEI_MAX_CONCURRENT,
|
||||
ENV_RERANKER_TEI_URL,
|
||||
ENV_RERANKER_ZEROENTROPY_API_KEY,
|
||||
)
|
||||
@@ -303,7 +290,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
|
||||
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
if self.bucket_batching and len(pairs) > 1:
|
||||
@@ -1679,7 +1665,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
return CohereCrossEncoder(
|
||||
api_key=api_key,
|
||||
model=config.reranker_openrouter_model,
|
||||
base_url="https://openrouter.ai/api/v1/rerank",
|
||||
base_url=config.reranker_openrouter_base_url,
|
||||
timeout=config.reranker_openrouter_timeout,
|
||||
)
|
||||
elif provider == "flashrank":
|
||||
|
||||
@@ -19,7 +19,6 @@ and mirrors Django's ``DatabaseOperations`` architecture.
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from .base import DatabaseConnection
|
||||
from .result import ResultRow
|
||||
|
||||
@@ -8,8 +8,6 @@ columns can't appear in GROUP BY).
|
||||
import json
|
||||
import uuid as uuid_mod
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from .base import DatabaseConnection
|
||||
from .ops import DataAccessOps, TagListingParts
|
||||
|
||||
@@ -4,11 +4,6 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
|
||||
efficient batch operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from .base import DatabaseConnection
|
||||
from .ops import DataAccessOps, TagListingParts
|
||||
from .result import ResultRow
|
||||
@@ -620,7 +615,6 @@ class PostgreSQLOps(DataAccessOps):
|
||||
per_entity_limit: int,
|
||||
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
|
||||
# v0.5.6 array ops: unnest, &&, COUNT(DISTINCT) on source_memory_ids.
|
||||
from ..schema import fq_table
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
f"""
|
||||
|
||||
@@ -106,6 +106,15 @@ SCHEMAS_WITH_PENDING_WORK = OptionalRoutine(
|
||||
deployment.
|
||||
* Should be cheap and idempotent — called every poll cycle (~30s).
|
||||
|
||||
The poller trusts the result wholesale: any schema the routine does
|
||||
not return is treated as having no work this cycle. It does NOT
|
||||
second-guess omissions with a per-schema scan — that would re-run the
|
||||
exact queries this routine exists to avoid. Consequently the routine
|
||||
is *only* appropriate for multi-tenant deployments. Single-schema
|
||||
(default/public only) installs should NOT create it: the per-schema
|
||||
fallback below is a single cheap EXISTS check that covers ``public``
|
||||
correctly and cannot starve.
|
||||
|
||||
Fallback when the routine is absent: per-schema ``EXISTS`` queries
|
||||
from Python (~4ms per schema). The server-side path is a single-
|
||||
round-trip optimisation worth ~200ms in deployments with thousands
|
||||
|
||||
@@ -26,11 +26,8 @@ from ..config import (
|
||||
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
|
||||
DEFAULT_EMBEDDINGS_PROVIDER,
|
||||
DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE,
|
||||
DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS,
|
||||
DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
|
||||
@@ -40,13 +37,6 @@ from ..config import (
|
||||
DEFAULT_ZEROENTROPY_BASE_URL,
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_GEMINI_API_KEY,
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL,
|
||||
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
|
||||
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
|
||||
ENV_EMBEDDINGS_ONNX_MODEL_ID,
|
||||
ENV_EMBEDDINGS_ONNX_MODEL_PATH,
|
||||
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH,
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY,
|
||||
ENV_EMBEDDINGS_OPENAI_BASE_URL,
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL,
|
||||
@@ -57,6 +47,7 @@ from ..config import (
|
||||
ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
|
||||
ENV_LLM_API_KEY,
|
||||
)
|
||||
from .bank_attribution import apply_bank_attribution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -705,6 +696,7 @@ class OpenAIEmbeddings(Embeddings):
|
||||
}
|
||||
if self.dimensions is not None:
|
||||
request["dimensions"] = self.dimensions
|
||||
apply_bank_attribution(request)
|
||||
|
||||
response = self._client.embeddings.create(**request)
|
||||
|
||||
@@ -1347,6 +1339,21 @@ class LiteLLMSDKEmbeddings(Embeddings):
|
||||
return all_embeddings
|
||||
|
||||
|
||||
# Gemini Embedding 2+ multimodal models return a SINGLE aggregated embedding
|
||||
# for a multi-input request instead of one vector per input (see
|
||||
# https://ai.google.dev/gemini-api/docs/embeddings#embedding-aggregation). For
|
||||
# these models we must embed one input per call to preserve the 1:1 input→vector
|
||||
# alignment the rest of the pipeline relies on. The marker matches preview and GA
|
||||
# names (e.g. "gemini-embedding-2-preview", "gemini-embedding-2"), with or
|
||||
# without a "google/" or "models/" prefix.
|
||||
_GEMINI_AGGREGATING_MODEL_MARKER = "gemini-embedding-2"
|
||||
|
||||
|
||||
def _gemini_model_aggregates_inputs(model: str) -> bool:
|
||||
"""Whether the model aggregates a multi-input request into one embedding."""
|
||||
return _GEMINI_AGGREGATING_MODEL_MARKER in model.lower()
|
||||
|
||||
|
||||
class GeminiEmbeddings(Embeddings):
|
||||
"""
|
||||
Google embeddings via the google.genai SDK.
|
||||
@@ -1356,6 +1363,10 @@ class GeminiEmbeddings(Embeddings):
|
||||
2. Vertex AI with service account or Application Default Credentials (ADC)
|
||||
|
||||
Uses the embed_content API: client.models.embed_content(model, contents)
|
||||
|
||||
Gemini Embedding 2+ multimodal models aggregate a multi-input request into a
|
||||
single embedding, so for those the batch size is forced to 1 (one input per
|
||||
call) to keep one vector per input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -1510,9 +1521,13 @@ class GeminiEmbeddings(Embeddings):
|
||||
|
||||
all_embeddings = []
|
||||
|
||||
# Gemini Embedding 2+ multimodal models return one aggregated vector for a
|
||||
# multi-input request, so embed one input per call to keep 1:1 alignment.
|
||||
batch_size = 1 if _gemini_model_aggregates_inputs(self.model) else self.batch_size
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(texts), self.batch_size):
|
||||
batch = texts[i : i + self.batch_size]
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
|
||||
embed_kwargs = {"model": self.model, "contents": batch}
|
||||
if self._embed_config is not None:
|
||||
@@ -1520,7 +1535,13 @@ class GeminiEmbeddings(Embeddings):
|
||||
|
||||
result = self._client.models.embed_content(**embed_kwargs)
|
||||
|
||||
all_embeddings.extend([emb.values for emb in result.embeddings])
|
||||
embeddings = result.embeddings or []
|
||||
if len(embeddings) != len(batch):
|
||||
raise RuntimeError(
|
||||
f"Gemini embeddings backend returned {len(embeddings)} vectors for "
|
||||
f"{len(batch)} input texts (model {self.model}); expected exact 1:1 alignment"
|
||||
)
|
||||
all_embeddings.extend([emb.values for emb in embeddings])
|
||||
|
||||
# L2-normalize when output_dimensionality is set — Gemini only returns
|
||||
# normalized vectors at full 3072 dims; truncated dims need re-normalization
|
||||
|
||||
@@ -834,14 +834,12 @@ class EntityResolver:
|
||||
|
||||
best_candidate = None
|
||||
best_score = 0.0
|
||||
best_name_similarity = 0.0
|
||||
|
||||
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
|
||||
|
||||
for row in candidates:
|
||||
candidate_id = row["id"]
|
||||
canonical_name = row["canonical_name"]
|
||||
metadata = row["metadata"]
|
||||
last_seen = row["last_seen"]
|
||||
score = 0.0
|
||||
|
||||
@@ -888,7 +886,6 @@ class EntityResolver:
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_candidate = candidate_id
|
||||
best_name_similarity = name_similarity
|
||||
|
||||
# Threshold for considering it the same entity
|
||||
threshold = 0.6
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.engine.memory_engine import BankLlmHealthInfo, Budget
|
||||
from hindsight_api.engine.response_models import RecallResult, ReflectResult
|
||||
from hindsight_api.engine.search.tags import TagsMatch
|
||||
from hindsight_api.models import RequestContext
|
||||
@@ -483,6 +483,20 @@ class MemoryEngineInterface(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def check_bank_llm(
|
||||
self,
|
||||
bank_id: str,
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
) -> "BankLlmHealthInfo":
|
||||
"""
|
||||
Probe the LLM consolidation would use for this bank. Deliberate connectivity
|
||||
test (one real minimal call); never returns the API key. See
|
||||
MemoryEngine.check_bank_llm.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_entity(
|
||||
self,
|
||||
|
||||
@@ -8,7 +8,7 @@ enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, et
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from .response_models import LLMToolCallResult, TokenUsage
|
||||
from .response_models import LLMToolCallResult
|
||||
|
||||
|
||||
class LLMInterface(ABC):
|
||||
|
||||
@@ -11,14 +11,10 @@ import time
|
||||
import uuid
|
||||
from contextlib import AsyncExitStack
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
|
||||
try:
|
||||
import google.auth
|
||||
from google.oauth2 import service_account
|
||||
|
||||
VERTEXAI_AVAILABLE = True
|
||||
@@ -27,16 +23,14 @@ except ImportError:
|
||||
|
||||
from ..config import (
|
||||
DEFAULT_LLM_MAX_CONCURRENT,
|
||||
DEFAULT_LLM_TIMEOUT,
|
||||
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT,
|
||||
ENV_LLM_GROQ_SERVICE_TIER,
|
||||
ENV_LLM_MAX_CONCURRENT,
|
||||
ENV_LLM_TIMEOUT,
|
||||
ENV_REFLECT_LLM_MAX_CONCURRENT,
|
||||
ENV_RETAIN_LLM_MAX_CONCURRENT,
|
||||
)
|
||||
from ..metrics import get_metrics_collector
|
||||
from .response_models import TokenUsage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .response_models import LLMToolCallResult
|
||||
|
||||
# Seed applied to every Groq request for deterministic behavior.
|
||||
DEFAULT_LLM_SEED = 4242
|
||||
@@ -232,6 +226,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
|
||||
"litellm",
|
||||
"litellmrouter",
|
||||
"bedrock",
|
||||
"nous",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -249,6 +244,7 @@ def create_llm_provider(
|
||||
reasoning_effort: str,
|
||||
groq_service_tier: str | None = None,
|
||||
openai_service_tier: str | None = None,
|
||||
bedrock_service_tier: str | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
vertexai_project_id: str | None = None,
|
||||
@@ -269,6 +265,7 @@ def create_llm_provider(
|
||||
reasoning_effort: Reasoning effort level for supported providers.
|
||||
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
|
||||
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
|
||||
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
|
||||
extra_body: Extra request-body params merged into the provider's native
|
||||
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
|
||||
VertexAI and LiteLLM providers (each merges them in its own parameter
|
||||
@@ -284,7 +281,6 @@ def create_llm_provider(
|
||||
Returns:
|
||||
LLMInterface implementation for the specified provider.
|
||||
"""
|
||||
from .llm_interface import LLMInterface
|
||||
from .providers import (
|
||||
AnthropicLLM,
|
||||
ClaudeCodeLLM,
|
||||
@@ -401,6 +397,7 @@ def create_llm_provider(
|
||||
model=bedrock_model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
extra_body=extra_body,
|
||||
bedrock_service_tier=bedrock_service_tier,
|
||||
)
|
||||
|
||||
elif provider_lower == "llamacpp":
|
||||
@@ -434,6 +431,21 @@ def create_llm_provider(
|
||||
extra_body=extra_body,
|
||||
)
|
||||
|
||||
elif provider_lower == "nous":
|
||||
# Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating
|
||||
# inference:invoke JWT auth read natively from ~/.hermes/auth.json
|
||||
# (no static api_key, no hermes_cli dependency — same shape as Codex).
|
||||
from hindsight_api.engine.providers.nous_llm import NousLLM
|
||||
|
||||
return NousLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
extra_body=extra_body,
|
||||
)
|
||||
|
||||
elif provider_lower in (
|
||||
"openai",
|
||||
"groq",
|
||||
@@ -478,6 +490,7 @@ class LLMProvider:
|
||||
reasoning_effort: str = "low",
|
||||
groq_service_tier: str | None = None,
|
||||
openai_service_tier: str | None = None,
|
||||
bedrock_service_tier: str | None = None,
|
||||
gemini_safety_settings: list | None = None,
|
||||
prompt_cache_enabled: bool = False,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
@@ -495,6 +508,7 @@ class LLMProvider:
|
||||
reasoning_effort: Reasoning effort level for supported providers.
|
||||
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
|
||||
openai_service_tier: OpenAI service tier (None or "flex") - from config.
|
||||
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
|
||||
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
|
||||
extra_body: Extra request-body params merged into the provider's native call
|
||||
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
|
||||
@@ -517,6 +531,7 @@ class LLMProvider:
|
||||
# Service tiers from hierarchical config (not env vars)
|
||||
self.groq_service_tier = groq_service_tier
|
||||
self.openai_service_tier = openai_service_tier
|
||||
self.bedrock_service_tier = bedrock_service_tier
|
||||
# Gemini safety settings (instance default; can be overridden per-request via context var)
|
||||
self.gemini_safety_settings = gemini_safety_settings
|
||||
# Gemini prompt caching: when True, retain extraction (and any future
|
||||
@@ -563,6 +578,7 @@ class LLMProvider:
|
||||
"zai",
|
||||
"opencode-go",
|
||||
"fireworks",
|
||||
"nous",
|
||||
]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
||||
@@ -587,6 +603,8 @@ class LLMProvider:
|
||||
self.base_url = "https://api.z.ai/api/coding/paas/v4"
|
||||
elif self.provider == "opencode-go":
|
||||
self.base_url = "https://opencode.ai/zen/go/v1"
|
||||
elif self.provider == "nous":
|
||||
self.base_url = "https://inference-api.nousresearch.com/v1"
|
||||
|
||||
# Prepare Vertex AI config (if applicable)
|
||||
vertexai_project_id = None
|
||||
@@ -679,6 +697,7 @@ class LLMProvider:
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
groq_service_tier=self.groq_service_tier,
|
||||
openai_service_tier=self.openai_service_tier,
|
||||
bedrock_service_tier=self.bedrock_service_tier,
|
||||
extra_body=self.extra_body,
|
||||
default_headers=self.default_headers,
|
||||
vertexai_project_id=vertexai_project_id,
|
||||
@@ -1120,6 +1139,7 @@ class LLMProvider:
|
||||
DEFAULT_LLM_REASONING_EFFORT,
|
||||
ENV_LLM_API_KEY,
|
||||
ENV_LLM_BASE_URL,
|
||||
ENV_LLM_BEDROCK_SERVICE_TIER,
|
||||
ENV_LLM_DEFAULT_HEADERS,
|
||||
ENV_LLM_EXTRA_BODY,
|
||||
ENV_LLM_MODEL,
|
||||
@@ -1151,6 +1171,7 @@ class LLMProvider:
|
||||
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
|
||||
extra_body=extra_body,
|
||||
default_headers=default_headers,
|
||||
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
||||
from hindsight_api.engine.llm_interface import LLMInterface
|
||||
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
||||
from hindsight_api.metrics import get_metrics_collector
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
||||
from hindsight_api.engine.llm_interface import LLMInterface
|
||||
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
||||
from hindsight_api.metrics import get_metrics_collector
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
||||
from hindsight_api.engine.llm_interface import LLMInterface
|
||||
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
||||
from hindsight_api.metrics import get_metrics_collector
|
||||
|
||||
@@ -397,7 +397,6 @@ class CodexLLM(LLMInterface):
|
||||
}
|
||||
|
||||
url = f"{self.base_url}/codex/responses"
|
||||
last_exception = None
|
||||
|
||||
# Manual attempt tracking instead of ``for attempt in range(...)`` so
|
||||
# that the reactive-refresh path can retry once without consuming a
|
||||
@@ -428,7 +427,6 @@ class CodexLLM(LLMInterface):
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = e
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
@@ -490,7 +488,6 @@ class CodexLLM(LLMInterface):
|
||||
return result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_exception = e
|
||||
status_code = e.response.status_code
|
||||
|
||||
# Auth error: try one OAuth refresh + retry before giving up.
|
||||
@@ -549,7 +546,6 @@ class CodexLLM(LLMInterface):
|
||||
raise
|
||||
|
||||
except httpx.RequestError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
@@ -564,10 +560,6 @@ class CodexLLM(LLMInterface):
|
||||
logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("Codex call failed after all retries")
|
||||
|
||||
async def _parse_sse_stream(self, response: httpx.Response) -> str:
|
||||
"""
|
||||
Parse Server-Sent Events (SSE) stream from Codex API.
|
||||
|
||||
@@ -8,9 +8,9 @@ This provider supports both:
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
@@ -19,7 +19,7 @@ from google import genai
|
||||
from google.genai import errors as genai_errors
|
||||
from google.genai import types as genai_types
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
||||
from hindsight_api.engine.llm_interface import LLMInterface
|
||||
from hindsight_api.engine.llm_wrapper import parse_llm_json
|
||||
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
||||
from hindsight_api.metrics import get_metrics_collector
|
||||
@@ -35,7 +35,6 @@ _safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settin
|
||||
|
||||
# Vertex AI imports (optional)
|
||||
try:
|
||||
import google.auth
|
||||
from google.oauth2 import service_account
|
||||
|
||||
VERTEXAI_AVAILABLE = True
|
||||
@@ -43,6 +42,14 @@ except ImportError:
|
||||
VERTEXAI_AVAILABLE = False
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
"""Coerce Gemini's optional/string completion counts to int, defaulting to 0."""
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
class GeminiLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider for Google Gemini and Vertex AI.
|
||||
@@ -826,6 +833,282 @@ class GeminiLLM(LLMInterface):
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
# ── Batch API (Gemini API only — not Vertex AI) ─────────────────────────
|
||||
#
|
||||
# Google's Gemini Batch API gives a flat 50% discount on input + output
|
||||
# tokens with a 24h completion SLA (https://ai.google.dev/gemini-api/docs/batch-api).
|
||||
# The retain orchestrator and ``fact_extraction`` consumer speak the
|
||||
# OpenAI-batch interface contract, so these overrides translate that shape
|
||||
# to/from Gemini's file-upload → ``batches.create`` → ``batches.get`` →
|
||||
# download flow — nothing downstream changes (same pattern as FireworksLLM).
|
||||
#
|
||||
# Interface contract preserved (see fact_extraction.py result handling)::
|
||||
# result["response"]["body"]["choices"][0]["message"]["content"]
|
||||
|
||||
async def supports_batch_api(self) -> bool:
|
||||
"""True for the Gemini API; False for Vertex AI.
|
||||
|
||||
Only ``provider="gemini"`` is supported: it exposes the file-upload
|
||||
Batch API used below. Vertex AI's batch path is GCS/BigQuery-backed (no
|
||||
file-upload analogue), so it stays unsupported here — the startup
|
||||
validation then surfaces a clear error instead of silently falling back
|
||||
to synchronous, full-price calls.
|
||||
"""
|
||||
return self.provider == "gemini"
|
||||
|
||||
async def submit_batch(
|
||||
self,
|
||||
requests: list[dict[str, Any]],
|
||||
endpoint: str = "/v1/chat/completions",
|
||||
completion_window: str = "24h",
|
||||
) -> dict[str, Any]:
|
||||
"""Submit a batch of (OpenAI-shaped) requests to the Gemini Batch API."""
|
||||
if not await self.supports_batch_api():
|
||||
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
|
||||
|
||||
# endpoint/completion_window are part of the shared LLMInterface batch
|
||||
# contract (used by the OpenAI path) but have no analogue on Gemini: the
|
||||
# request shape is fixed (generateContent) and the SLA is server-side.
|
||||
# Kept for signature compatibility with the shared retain driver.
|
||||
logger.info(f"Submitting Gemini batch with {len(requests)} requests")
|
||||
|
||||
jsonl = self._translate_requests(requests)
|
||||
|
||||
# Upload the JSONL as a Gemini file (mime_type must be "jsonl"; a
|
||||
# BytesIO has no path for the SDK to infer it from).
|
||||
file_obj = io.BytesIO(jsonl.encode("utf-8"))
|
||||
uploaded = await self._client.aio.files.upload(
|
||||
file=file_obj,
|
||||
config=genai_types.UploadFileConfig(mime_type="jsonl", display_name="hindsight-batch-input"),
|
||||
)
|
||||
|
||||
batch = await self._client.aio.batches.create(
|
||||
model=self.model,
|
||||
src=uploaded.name,
|
||||
config=genai_types.CreateBatchJobConfig(display_name="hindsight-batch"),
|
||||
)
|
||||
|
||||
logger.info(f"Gemini batch submitted: {batch.name}, state={self._state_name(batch.state)}")
|
||||
|
||||
return {
|
||||
"batch_id": batch.name,
|
||||
"status": self._normalize_state(batch.state),
|
||||
"input_file_id": uploaded.name,
|
||||
"request_count": len(requests),
|
||||
}
|
||||
|
||||
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a Gemini batch job, in the shared status shape."""
|
||||
if not await self.supports_batch_api():
|
||||
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
|
||||
|
||||
batch = await self._client.aio.batches.get(name=batch_id)
|
||||
|
||||
stats = batch.completion_stats
|
||||
successful = _to_int(getattr(stats, "successful_count", None)) if stats else 0
|
||||
failed = _to_int(getattr(stats, "failed_count", None)) if stats else 0
|
||||
incomplete = _to_int(getattr(stats, "incomplete_count", None)) if stats else 0
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"batch_id": batch.name,
|
||||
"status": self._normalize_state(batch.state),
|
||||
"request_counts": {
|
||||
"total": successful + failed + incomplete,
|
||||
"completed": successful,
|
||||
"failed": failed,
|
||||
},
|
||||
}
|
||||
|
||||
if batch.dest and getattr(batch.dest, "file_name", None):
|
||||
result["output_file_id"] = batch.dest.file_name
|
||||
if batch.error:
|
||||
result["errors"] = self._error_to_dict(batch.error)
|
||||
|
||||
return result
|
||||
|
||||
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
|
||||
"""Download and normalize completed Gemini batch results."""
|
||||
if not await self.supports_batch_api():
|
||||
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
|
||||
|
||||
batch = await self._client.aio.batches.get(name=batch_id)
|
||||
|
||||
status = self._normalize_state(batch.state)
|
||||
if status != "completed":
|
||||
raise ValueError(f"Gemini batch {batch_id} is not completed yet (state: {self._state_name(batch.state)})")
|
||||
|
||||
dest = batch.dest
|
||||
if not dest or not getattr(dest, "file_name", None):
|
||||
raise ValueError(
|
||||
f"Gemini batch {batch_id} completed but reported no output file "
|
||||
f"(submit_batch always uses file mode, so this is unexpected)"
|
||||
)
|
||||
|
||||
content = await self._client.aio.files.download(file=dest.file_name)
|
||||
text = content.decode("utf-8") if isinstance(content, (bytes, bytearray)) else str(content)
|
||||
|
||||
# The output is a JSONL error file plus results merged into one stream;
|
||||
# error lines carry an `error` so partial failures surface per key
|
||||
# instead of vanishing (JOB_STATE_PARTIALLY_SUCCEEDED maps to completed).
|
||||
results: list[dict[str, Any]] = []
|
||||
for line in text.strip().split("\n"):
|
||||
if line.strip():
|
||||
results.append(self._normalize_output_line(json.loads(line)))
|
||||
|
||||
logger.info(f"Retrieved {len(results)} results for Gemini batch {batch_id}")
|
||||
return results
|
||||
|
||||
# ----- pure translation/normalization helpers (unit-tested) ----------
|
||||
|
||||
@staticmethod
|
||||
def _translate_requests(requests: list[dict[str, Any]]) -> str:
|
||||
"""OpenAI batch requests -> Gemini batch input JSONL.
|
||||
|
||||
Each output line is ``{"key": <custom_id>, "request": <GenerateContentRequest>}``;
|
||||
the model is supplied to ``batches.create`` so it is omitted per-line.
|
||||
"""
|
||||
lines = []
|
||||
for req in requests:
|
||||
gemini_request = GeminiLLM._openai_body_to_gemini_request(req.get("body") or {})
|
||||
lines.append(json.dumps({"key": req.get("custom_id"), "request": gemini_request}, ensure_ascii=False))
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _openai_body_to_gemini_request(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""OpenAI chat-completions body -> Gemini ``GenerateContentRequest`` JSON.
|
||||
|
||||
Mirrors the synchronous ``call`` path: system messages become
|
||||
``systemInstruction``; a ``response_format`` json_schema forces JSON
|
||||
output (``responseMimeType``), appends the schema as a textual hint, and
|
||||
grammar-enforces via ``responseJsonSchema`` when ``strict`` is set.
|
||||
"""
|
||||
system_texts: list[str] = []
|
||||
contents: list[dict[str, Any]] = []
|
||||
for msg in body.get("messages") or []:
|
||||
role = msg.get("role", "user")
|
||||
text = msg.get("content", "") or ""
|
||||
if role == "system":
|
||||
system_texts.append(text)
|
||||
elif role == "assistant":
|
||||
contents.append({"role": "model", "parts": [{"text": text}]})
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [{"text": text}]})
|
||||
|
||||
generation_config: dict[str, Any] = {}
|
||||
if body.get("temperature") is not None:
|
||||
generation_config["temperature"] = body["temperature"]
|
||||
if body.get("max_completion_tokens") is not None:
|
||||
generation_config["maxOutputTokens"] = body["max_completion_tokens"]
|
||||
|
||||
response_format = body.get("response_format")
|
||||
if isinstance(response_format, dict) and response_format.get("type") == "json_schema":
|
||||
json_schema = response_format.get("json_schema") or {}
|
||||
schema = json_schema.get("schema")
|
||||
generation_config["responseMimeType"] = "application/json"
|
||||
if schema:
|
||||
system_texts.append(
|
||||
"You must respond with valid JSON matching this schema:\n" + json.dumps(schema, ensure_ascii=False)
|
||||
)
|
||||
if json_schema.get("strict"):
|
||||
generation_config["responseJsonSchema"] = schema
|
||||
|
||||
request: dict[str, Any] = {"contents": contents}
|
||||
if system_texts:
|
||||
request["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_texts)}]}
|
||||
if generation_config:
|
||||
request["generationConfig"] = generation_config
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def _normalize_output_line(line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Gemini batch output line -> OpenAI-batch-output shape.
|
||||
|
||||
Target: ``{"custom_id", "response": {"body": {"choices": [...], "usage": {...}}}, "error"}``
|
||||
so the consumer's ``result["response"]["body"]["choices"][0]...`` works and
|
||||
it can read ``body["usage"]`` for token accounting (the consumer reports
|
||||
zero usage otherwise).
|
||||
"""
|
||||
custom_id = line.get("key") if line.get("key") is not None else line.get("custom_id")
|
||||
error = line.get("error")
|
||||
if error:
|
||||
return {"custom_id": custom_id, "response": None, "error": error}
|
||||
|
||||
response = line.get("response") or {}
|
||||
body: dict[str, Any] = {"choices": [{"message": {"content": GeminiLLM._extract_text_from_response(response)}}]}
|
||||
usage = GeminiLLM._usage_from_response(response)
|
||||
if usage is not None:
|
||||
body["usage"] = usage
|
||||
return {"custom_id": custom_id, "response": {"body": body}, "error": None}
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_from_response(response: dict[str, Any]) -> str:
|
||||
"""Concatenate the text parts of a (JSON) GenerateContentResponse."""
|
||||
candidates = response.get("candidates") or []
|
||||
if not candidates:
|
||||
return ""
|
||||
content = candidates[0].get("content") or {}
|
||||
parts = content.get("parts") or []
|
||||
return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("text"))
|
||||
|
||||
@staticmethod
|
||||
def _usage_from_response(response: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Gemini ``usageMetadata`` -> OpenAI-shaped ``usage`` block, or None.
|
||||
|
||||
The batch consumer accumulates token usage from ``body["usage"]`` using
|
||||
OpenAI key names, so translate here to keep the output contract uniform
|
||||
across providers. Handles both the REST camelCase (downloaded JSONL) and
|
||||
snake_case spellings defensively.
|
||||
"""
|
||||
meta = response.get("usageMetadata") or response.get("usage_metadata")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
prompt = meta.get("promptTokenCount") or meta.get("prompt_token_count") or 0
|
||||
completion = meta.get("candidatesTokenCount") or meta.get("candidates_token_count") or 0
|
||||
total = meta.get("totalTokenCount") or meta.get("total_token_count") or 0
|
||||
return {"prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": total}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_state(state: Any) -> str:
|
||||
"""Gemini ``JobState`` -> the retain driver's status strings.
|
||||
|
||||
Unknown / in-flight states map to ``in_progress`` so the driver keeps
|
||||
polling; ``PARTIALLY_SUCCEEDED`` maps to ``completed`` (per-line errors
|
||||
surface the partial failures during retrieval).
|
||||
"""
|
||||
name = GeminiLLM._state_name(state).upper()
|
||||
if name in ("JOB_STATE_SUCCEEDED", "JOB_STATE_PARTIALLY_SUCCEEDED"):
|
||||
return "completed"
|
||||
if name == "JOB_STATE_FAILED":
|
||||
return "failed"
|
||||
if name in ("JOB_STATE_CANCELLED", "JOB_STATE_CANCELLING"):
|
||||
return "cancelled"
|
||||
if name == "JOB_STATE_EXPIRED":
|
||||
return "expired"
|
||||
return "in_progress"
|
||||
|
||||
@staticmethod
|
||||
def _state_name(state: Any) -> str:
|
||||
"""Extract the bare ``JOB_STATE_*`` name from a JobState enum or string."""
|
||||
if state is None:
|
||||
return ""
|
||||
name = getattr(state, "name", None)
|
||||
if name:
|
||||
return str(name)
|
||||
text = str(state)
|
||||
if "." in text:
|
||||
text = text.rsplit(".", 1)[-1]
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _error_to_dict(error: Any) -> dict[str, Any]:
|
||||
"""Coerce a Gemini JobError into a JSON-serializable dict for logging."""
|
||||
if hasattr(error, "model_dump"):
|
||||
try:
|
||||
return error.model_dump(exclude_none=True)
|
||||
except Exception:
|
||||
pass
|
||||
return {"message": str(error)}
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources (close connections, etc.)."""
|
||||
# Gemini client doesn't require explicit cleanup
|
||||
|
||||
@@ -49,6 +49,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
reasoning_effort: str = "low",
|
||||
timeout: float = 300.0,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
bedrock_service_tier: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
||||
@@ -60,6 +61,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
# drops any the target model rejects (litellm.drop_params=True below).
|
||||
# Sourced from llm_extra_body (env: HINDSIGHT_API_LLM_EXTRA_BODY).
|
||||
self._extra_body: dict[str, Any] = extra_body or {}
|
||||
self.bedrock_service_tier = bedrock_service_tier
|
||||
|
||||
try:
|
||||
import litellm
|
||||
@@ -119,6 +121,10 @@ class LiteLLMLLM(LLMInterface):
|
||||
for key, value in self._extra_body.items():
|
||||
kwargs.setdefault(key, value)
|
||||
|
||||
# Bedrock service tier: flex (50% cheaper), priority, or reserved
|
||||
if self.model.startswith("bedrock/") and self.bedrock_service_tier is not None:
|
||||
kwargs["service_tier"] = self.bedrock_service_tier
|
||||
|
||||
return kwargs
|
||||
|
||||
# ── per-model output-tokens cap (shared with Router subclass) ────────────
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
Native Nous Portal OAuth authentication manager.
|
||||
|
||||
The Nous Portal inference endpoint (https://inference-api.nousresearch.com/v1)
|
||||
speaks the OpenAI-compatible wire format but authenticates with a short-lived,
|
||||
inference-scoped JWT rather than a static API key. Hermes obtains that JWT once
|
||||
via an interactive browser login (``hermes portal``) and persists the resulting
|
||||
OAuth state — ``access_token`` + ``refresh_token`` — under ``providers.nous`` in
|
||||
``~/.hermes/auth.json``.
|
||||
|
||||
This manager reads that file *directly* and refreshes the access token itself,
|
||||
exactly mirroring ``codex_auth.py`` (read ``~/.codex/auth.json`` + native
|
||||
refresh). It deliberately does **not** import the Hermes ``hermes_cli`` package:
|
||||
that package is the interactive CLI, not a library Hindsight can depend on. The
|
||||
refresh request shape is mirrored from Hermes' own resolver
|
||||
(``POST {portal}/api/oauth/token`` with an ``x-nous-refresh-token`` header and a
|
||||
``grant_type=refresh_token`` form body), so server-side changes affect both
|
||||
clients identically. The inference bearer is the access token itself — in
|
||||
Hermes' state the ``agent_key`` field is literally ``= access_token``.
|
||||
|
||||
Single-use refresh tokens
|
||||
-------------------------
|
||||
Nous refresh tokens are single-use with server-side reuse-detection: if two
|
||||
processes refresh with the same ``refresh_token``, or a rotated token is not
|
||||
persisted back, the Portal revokes the whole session as a theft signal. Because
|
||||
Hindsight shares ``~/.hermes/auth.json`` with a possibly-running Hermes agent,
|
||||
every refresh here is performed while holding the **same cross-process advisory
|
||||
lock Hermes uses** (``~/.hermes/auth.lock`` via ``fcntl.flock``) and re-reads the
|
||||
latest ``refresh_token`` from disk under that lock before exchanging it. That is
|
||||
the protocol Hermes follows too, so the two coordinate safely through the file.
|
||||
|
||||
Usage
|
||||
-----
|
||||
mgr = NousAuthManager.from_file()
|
||||
token = mgr.ensure_fresh_token() # proactive; refreshes if near expiry
|
||||
... # use token as Bearer
|
||||
mgr.refresh_tokens(force=True) # reactive, on a 401
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants — mirrored from Hermes' canonical Nous resolver
|
||||
# (hermes_cli/auth.py: DEFAULT_NOUS_* and _refresh_access_token). Endpoints and
|
||||
# client id are overridable via the same env vars Hermes honours, so a staging
|
||||
# Portal or a future change can be pointed at without a code change.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NOUS_PORTAL_BASE_URL = (
|
||||
os.environ.get("HERMES_PORTAL_BASE_URL")
|
||||
or os.environ.get("NOUS_PORTAL_BASE_URL")
|
||||
or "https://portal.nousresearch.com"
|
||||
)
|
||||
_NOUS_INFERENCE_BASE_URL = os.environ.get("NOUS_INFERENCE_BASE_URL") or "https://inference-api.nousresearch.com/v1"
|
||||
_NOUS_CLIENT_ID = "hermes-cli"
|
||||
|
||||
# Proactively refresh this many seconds before the JWT ``exp`` claim — matches
|
||||
# the 120s skew Hermes' own runtime resolver uses for Nous.
|
||||
_NOUS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
|
||||
# OAuth error codes the Portal returns when the refresh_token itself is no
|
||||
# longer usable. These are terminal — retrying will not succeed; the user must
|
||||
# re-run ``hermes portal``.
|
||||
_NOUS_TERMINAL_REFRESH_ERROR_CODES = frozenset(
|
||||
{"invalid_grant", "invalid_token", "refresh_token_reused", "refresh_token_expired"}
|
||||
)
|
||||
|
||||
_AUTH_LOCK_TIMEOUT_SECONDS = 20.0
|
||||
|
||||
|
||||
def _default_auth_file() -> Path:
|
||||
return Path.home() / ".hermes" / "auth.json"
|
||||
|
||||
|
||||
class NousNotLoggedInError(RuntimeError):
|
||||
"""Raised when ``~/.hermes/auth.json`` has no usable Nous OAuth state.
|
||||
|
||||
Remediation: run ``hermes portal`` to log in to Nous Portal.
|
||||
"""
|
||||
|
||||
|
||||
class NousRefreshExpiredError(RuntimeError):
|
||||
"""Raised when the Nous refresh_token itself is permanently invalid.
|
||||
|
||||
The user must re-run ``hermes portal`` to obtain new credentials. Callers
|
||||
should surface a clear remediation message and stop retrying.
|
||||
"""
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _hermes_auth_lock(auth_file: Path, timeout_seconds: float = _AUTH_LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
|
||||
"""Cross-process advisory lock on the Hermes auth store.
|
||||
|
||||
Uses ``<auth_file>.lock`` (i.e. ``~/.hermes/auth.lock``) with
|
||||
``fcntl.flock(LOCK_EX)`` — the exact same lock file and primitive Hermes'
|
||||
``_auth_store_lock`` takes — so a refresh here is mutually exclusive with a
|
||||
concurrently-running Hermes agent. Degrades to a no-op (with a debug log)
|
||||
where ``fcntl`` is unavailable (Windows); the single-process in-memory lock
|
||||
still serialises this process's own refreshes.
|
||||
"""
|
||||
if fcntl is None: # pragma: no cover - Windows
|
||||
logger.debug("fcntl unavailable; Nous refresh proceeds without a cross-process lock.")
|
||||
yield
|
||||
return
|
||||
|
||||
lock_path = auth_file.with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(lock_path, "a+") as lock_file:
|
||||
deadline = time.monotonic() + max(1.0, timeout_seconds)
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except (BlockingIOError, OSError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("Timed out waiting for the Hermes auth store lock") from None
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class NousAuthManager:
|
||||
"""Sync Nous Portal OAuth credential manager.
|
||||
|
||||
Holds the access_token + refresh_token in memory and handles
|
||||
proactive/reactive refresh. A ``threading.Lock`` gives single-flight
|
||||
semantics within the process; the cross-process ``fcntl`` lock guards
|
||||
against a concurrent Hermes agent (see module docstring).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: str | None,
|
||||
auth_file: Path,
|
||||
*,
|
||||
portal_base_url: str = _NOUS_PORTAL_BASE_URL,
|
||||
inference_base_url: str = _NOUS_INFERENCE_BASE_URL,
|
||||
client_id: str = _NOUS_CLIENT_ID,
|
||||
) -> None:
|
||||
self.access_token = access_token
|
||||
self.refresh_token = refresh_token
|
||||
self._auth_file = auth_file
|
||||
self._portal_base_url = portal_base_url.rstrip("/")
|
||||
self._inference_base_url = inference_base_url.rstrip("/")
|
||||
self._client_id = client_id
|
||||
self._lock = threading.Lock()
|
||||
self._http_client = httpx.Client(timeout=30.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, auth_file: Path | None = None) -> "NousAuthManager":
|
||||
"""Build a manager from ``providers.nous`` in the Hermes auth store.
|
||||
|
||||
Raises
|
||||
------
|
||||
NousNotLoggedInError:
|
||||
If the file is missing, unreadable, or has no Nous OAuth state with
|
||||
an ``access_token``.
|
||||
"""
|
||||
if auth_file is None:
|
||||
auth_file = _default_auth_file()
|
||||
|
||||
if not auth_file.exists():
|
||||
raise NousNotLoggedInError(
|
||||
f"Hermes auth file not found: {auth_file}. Run 'hermes portal' to log in to Nous Portal."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(auth_file) as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise NousNotLoggedInError(f"Could not read Hermes auth file {auth_file}: {type(e).__name__}") from e
|
||||
|
||||
state = cls._nous_state(data)
|
||||
if not state:
|
||||
raise NousNotLoggedInError(
|
||||
"Hermes is not logged into Nous Portal (no providers.nous OAuth state). Run 'hermes portal'."
|
||||
)
|
||||
|
||||
access_token = state.get("access_token")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise NousNotLoggedInError("Nous OAuth state has no access_token. Re-authenticate with 'hermes portal'.")
|
||||
|
||||
return cls(
|
||||
access_token=access_token,
|
||||
refresh_token=state.get("refresh_token"),
|
||||
auth_file=auth_file,
|
||||
portal_base_url=cls._optional_url(state.get("portal_base_url")) or _NOUS_PORTAL_BASE_URL,
|
||||
inference_base_url=cls._optional_url(state.get("inference_base_url")) or _NOUS_INFERENCE_BASE_URL,
|
||||
client_id=str(state.get("client_id") or _NOUS_CLIENT_ID),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _nous_state(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Pull the ``providers.nous`` state dict out of a loaded auth store."""
|
||||
providers = data.get("providers")
|
||||
if not isinstance(providers, dict):
|
||||
return {}
|
||||
state = providers.get("nous")
|
||||
return state if isinstance(state, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _optional_url(value: Any) -> str | None:
|
||||
return value.rstrip("/") if isinstance(value, str) and value.strip() else None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return self._inference_base_url
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token state
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def load_refresh_token_from_file(auth_file: Path) -> str | None:
|
||||
"""Read ``providers.nous.refresh_token`` from ``auth_file``.
|
||||
|
||||
Returns ``None`` when the file is unreadable or omits the field. Does
|
||||
not raise — the caller degrades to using the in-memory token.
|
||||
"""
|
||||
try:
|
||||
with open(auth_file) as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return NousAuthManager._nous_state(data).get("refresh_token")
|
||||
|
||||
@staticmethod
|
||||
def _decode_jwt_exp_unixtime(token: str) -> int | None:
|
||||
"""Return the JWT ``exp`` claim as a unix timestamp, or None on failure.
|
||||
|
||||
The signature is not verified — the server is the source of truth on
|
||||
acceptance. This only schedules proactive refresh.
|
||||
"""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
payload_b64 = parts[1]
|
||||
padding = "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding).decode("utf-8"))
|
||||
exp = payload.get("exp")
|
||||
return int(exp) if exp is not None else None
|
||||
except (ValueError, TypeError, json.JSONDecodeError, binascii.Error):
|
||||
return None
|
||||
|
||||
def _token_is_stale(self, skew_seconds: int = _NOUS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
|
||||
"""True when the cached access_token is past expiry (with skew).
|
||||
|
||||
Returns False when expiry cannot be determined — we'd rather use a
|
||||
possibly-expired token and recover via the reactive 401 path than
|
||||
refresh aggressively on every request when ``exp`` is unparseable.
|
||||
"""
|
||||
exp = self._decode_jwt_exp_unixtime(self.access_token)
|
||||
if exp is None:
|
||||
return False
|
||||
return exp <= int(time.time()) + skew_seconds
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _persist_state_atomic(self, updated: dict[str, Any]) -> None:
|
||||
"""Patch ``providers.nous`` in ``_auth_file`` and write atomically.
|
||||
|
||||
Re-reads the on-disk store first so fields written by Hermes (other
|
||||
providers, the credential pool, rotated tokens) are never clobbered,
|
||||
then patches only the Nous OAuth fields and ``os.replace``s into place
|
||||
(atomic on POSIX within the same filesystem). Must be called while
|
||||
holding :func:`_hermes_auth_lock`.
|
||||
"""
|
||||
try:
|
||||
with open(self._auth_file) as f:
|
||||
loaded = json.load(f)
|
||||
current: dict[str, Any] = loaded if isinstance(loaded, dict) else {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
current = {}
|
||||
|
||||
providers = current.get("providers")
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
current["providers"] = providers
|
||||
state = providers.get("nous")
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
providers["nous"] = state
|
||||
|
||||
state.update(updated)
|
||||
# The inference bearer is the access token itself; keep agent_key in
|
||||
# sync so Hermes' own resolver/status sees the rotation too.
|
||||
state["agent_key"] = updated.get("access_token", state.get("access_token"))
|
||||
current["updated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
parent = self._auth_file.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".auth.", suffix=".json.tmp", dir=str(parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(current, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, self._auth_file)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Refresh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_oauth_error_code(response: httpx.Response) -> str | None:
|
||||
"""Pull the OAuth error code out of a 4xx refresh response, if present."""
|
||||
try:
|
||||
body = response.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
err = body.get("error")
|
||||
if isinstance(err, str):
|
||||
return err
|
||||
if isinstance(err, dict) and isinstance(err.get("code"), str):
|
||||
return err["code"]
|
||||
code = body.get("error_code")
|
||||
return code if isinstance(code, str) else None
|
||||
|
||||
def refresh_tokens(self, reason: str = "", *, force: bool = False) -> None:
|
||||
"""Single-flight Nous OAuth token refresh.
|
||||
|
||||
Serialised through ``self._lock`` (in-process single-flight) and
|
||||
:func:`_hermes_auth_lock` (cross-process, vs a running Hermes agent).
|
||||
The latest ``refresh_token`` is re-read from disk under the lock before
|
||||
the exchange — single-use tokens make using a stale in-memory RT a
|
||||
session-revoking mistake.
|
||||
|
||||
Raises
|
||||
------
|
||||
NousRefreshExpiredError:
|
||||
On a terminal refresh error (expired/reused/invalid grant).
|
||||
RuntimeError:
|
||||
For other refresh failures (network, 5xx, missing refresh_token).
|
||||
"""
|
||||
token_before_lock = self.access_token
|
||||
with self._lock:
|
||||
if force:
|
||||
if self.access_token != token_before_lock:
|
||||
return # another caller already refreshed while we waited
|
||||
elif not self._token_is_stale():
|
||||
return
|
||||
|
||||
with _hermes_auth_lock(self._auth_file):
|
||||
# Re-read the freshest refresh_token persisted by whoever rotated
|
||||
# last (this process or Hermes). Using a stale RT is exactly what
|
||||
# trips the Portal's single-use reuse-detection.
|
||||
disk_rt = self.load_refresh_token_from_file(self._auth_file)
|
||||
if disk_rt:
|
||||
self.refresh_token = disk_rt
|
||||
|
||||
if not self.refresh_token:
|
||||
raise RuntimeError(
|
||||
"Nous access_token is expired but no refresh_token is available. "
|
||||
"Run 'hermes portal' to re-authenticate."
|
||||
)
|
||||
|
||||
log_reason = f" ({reason})" if reason else ""
|
||||
logger.info(f"Refreshing Nous Portal access_token{log_reason}")
|
||||
|
||||
try:
|
||||
response = self._http_client.post(
|
||||
f"{self._portal_base_url}/api/oauth/token",
|
||||
headers={"x-nous-refresh-token": self.refresh_token},
|
||||
data={"grant_type": "refresh_token", "client_id": self._client_id},
|
||||
timeout=30.0,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise RuntimeError(f"Nous OAuth refresh network error: {type(e).__name__}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
code = self._extract_oauth_error_code(response)
|
||||
if code in _NOUS_TERMINAL_REFRESH_ERROR_CODES or response.status_code in (400, 401):
|
||||
raise NousRefreshExpiredError(
|
||||
f"Nous refresh_token is no longer valid (status={response.status_code}, "
|
||||
f"error={code or 'none'}). Run 'hermes portal' to re-authenticate."
|
||||
)
|
||||
raise RuntimeError(f"Nous OAuth refresh failed with HTTP {response.status_code}")
|
||||
|
||||
try:
|
||||
body = response.json()
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise RuntimeError(f"Nous OAuth refresh returned non-JSON body: {e}") from e
|
||||
|
||||
new_access = body.get("access_token")
|
||||
if not new_access:
|
||||
raise RuntimeError("Nous OAuth refresh returned no access_token")
|
||||
new_refresh = body.get("refresh_token") or self.refresh_token
|
||||
|
||||
# Update in-memory state first so waiters see fresh credentials
|
||||
# even if the disk write fails.
|
||||
self.access_token = new_access
|
||||
self.refresh_token = new_refresh
|
||||
|
||||
persisted: dict[str, Any] = {"access_token": new_access, "refresh_token": new_refresh}
|
||||
expires_in = body.get("expires_in")
|
||||
if isinstance(expires_in, (int, float)):
|
||||
persisted["expires_at"] = datetime.fromtimestamp(
|
||||
time.time() + float(expires_in), tz=timezone.utc
|
||||
).isoformat()
|
||||
try:
|
||||
self._persist_state_atomic(persisted)
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
f"Nous refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
|
||||
"In-memory credentials are current; the on-disk rotated token was not saved."
|
||||
)
|
||||
logger.info("Nous Portal access_token refreshed successfully")
|
||||
|
||||
def ensure_fresh_token(self) -> str:
|
||||
"""Refresh proactively if near/at expiry, then return the bearer token.
|
||||
|
||||
Cheap when fresh (a JWT exp decode + comparison).
|
||||
"""
|
||||
if self._token_is_stale():
|
||||
self.refresh_tokens(reason="proactive (token near expiry)")
|
||||
return self.access_token
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
self._http_client.close()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Nous Portal LLM provider for Hindsight.
|
||||
|
||||
Thin wrapper over :class:`OpenAICompatibleLLM`. The Nous Portal speaks the
|
||||
OpenAI chat-completions wire format, so all request/response handling is
|
||||
inherited unchanged. The only thing Nous needs on top is a rotating,
|
||||
inference-scoped JWT (there is no static API key in the Hermes login flow),
|
||||
which :class:`NousAuthManager` reads from ``~/.hermes/auth.json`` and refreshes
|
||||
natively — the same pattern as the Codex provider, with no dependency on the
|
||||
``hermes_cli`` package. See ``nous_auth.py`` for the auth mechanics.
|
||||
|
||||
Configure with::
|
||||
|
||||
llm_provider = "nous"
|
||||
llm_base_url = "https://inference-api.nousresearch.com/v1" # or omit
|
||||
llm_model = "deepseek/deepseek-v4-flash" # any Nous slug
|
||||
|
||||
No API key is set in config; the token comes from the shared Hermes auth store
|
||||
after a one-time ``hermes portal`` login.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from openai import APIStatusError, AsyncOpenAI
|
||||
|
||||
from hindsight_api.engine.providers.nous_auth import (
|
||||
NousAuthManager,
|
||||
NousNotLoggedInError,
|
||||
NousRefreshExpiredError,
|
||||
)
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["NousLLM", "NousAuthManager", "NousNotLoggedInError", "NousRefreshExpiredError"]
|
||||
|
||||
|
||||
class NousLLM(OpenAICompatibleLLM):
|
||||
"""OpenAI-compatible provider for the Nous Portal with rotating-JWT auth."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str, # Ignored — the token is read from ~/.hermes/auth.json
|
||||
base_url: str,
|
||||
model: str,
|
||||
reasoning_effort: str = "low",
|
||||
**kwargs: Any,
|
||||
):
|
||||
try:
|
||||
self._auth = NousAuthManager.from_file()
|
||||
except NousNotLoggedInError as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to load Nous Portal credentials: {e}\n\n"
|
||||
"To set up Nous authentication:\n"
|
||||
"1. Install Hermes: https://hermes-agent.nousresearch.com\n"
|
||||
"2. Log in to Nous Portal: hermes portal\n"
|
||||
"3. Verify: hermes portal status\n\n"
|
||||
"Or use a different provider (openai, anthropic, gemini) with an API key."
|
||||
) from e
|
||||
|
||||
# Single-flight async refresh lock — concurrent coroutines racing toward
|
||||
# an expired token produce one network refresh.
|
||||
self._auth_lock = asyncio.Lock()
|
||||
|
||||
token = self._auth.access_token
|
||||
resolved_base = base_url or self._auth.base_url
|
||||
# Parent validates provider against a fixed list; present as "openai"
|
||||
# (identical wire format) while retaining the true identity for logs.
|
||||
super().__init__(
|
||||
provider="openai",
|
||||
api_key=token,
|
||||
base_url=resolved_base,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
**kwargs,
|
||||
)
|
||||
self._nous_provider_name = provider
|
||||
logger.info(
|
||||
"Nous LLM initialized: model=%s base_url=%s (rotating inference:invoke JWT)",
|
||||
self.model,
|
||||
self.base_url,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rebuild_client(self) -> None:
|
||||
"""Rebuild the OpenAI SDK client against the current token."""
|
||||
self.api_key = self._auth.access_token
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
max_retries=0,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
async def _ensure_fresh_token(self) -> None:
|
||||
"""Proactively refresh if the JWT is near expiry; rebuild on change.
|
||||
|
||||
Cheap when fresh (a JWT exp decode). The blocking refresh (network +
|
||||
cross-process file lock) is offloaded to a thread so the event loop is
|
||||
never stalled.
|
||||
"""
|
||||
if not self._auth._token_is_stale():
|
||||
return
|
||||
await self._refresh(reason="proactive (token near expiry)", force=False)
|
||||
|
||||
async def _refresh(self, *, reason: str, force: bool) -> None:
|
||||
token_before = self.api_key
|
||||
async with self._auth_lock:
|
||||
if force:
|
||||
if self.api_key != token_before:
|
||||
return # another coroutine already refreshed
|
||||
elif not self._auth._token_is_stale():
|
||||
return
|
||||
await asyncio.to_thread(lambda: self._auth.refresh_tokens(reason, force=force))
|
||||
if self._auth.access_token != self.api_key:
|
||||
self._rebuild_client()
|
||||
|
||||
async def _with_auth_retry(self, fn: Any, label: str, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Run an OpenAI-compatible call, refreshing once on a 401.
|
||||
|
||||
The proactive refresh covers most expiries; a token can still be
|
||||
rejected mid-flight if Hermes rotated it out from under us or the exp
|
||||
claim was unparseable. One reactive refresh + retry is the safety net.
|
||||
"""
|
||||
await self._ensure_fresh_token()
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
except APIStatusError as e:
|
||||
if getattr(e, "status_code", None) != 401:
|
||||
raise
|
||||
logger.warning("Nous 401 (%s) — forcing token refresh and retrying once.", label)
|
||||
try:
|
||||
await self._refresh(reason=f"reactive (HTTP 401 on {label})", force=True)
|
||||
except NousRefreshExpiredError as refresh_err:
|
||||
raise RuntimeError(
|
||||
"Nous authentication failed and the refresh_token is no longer valid.\n"
|
||||
"Run 'hermes portal' to re-authenticate."
|
||||
) from refresh_err
|
||||
return await fn(*args, **kwargs)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overrides
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
await self._ensure_fresh_token()
|
||||
return await super().verify_connection()
|
||||
|
||||
async def call(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._with_auth_retry(super().call, "call", *args, **kwargs)
|
||||
|
||||
async def call_with_tools(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._with_auth_retry(super().call_with_tools, "call_with_tools", *args, **kwargs)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
self._auth.close()
|
||||
parent_cleanup = getattr(super(), "cleanup", None)
|
||||
if parent_cleanup is not None:
|
||||
await parent_cleanup()
|
||||