Compare commits

...
Author SHA1 Message Date
Ben 05395d9815 blog(agent-framework): co-branded cover image
Replace the placeholder hero with the Hindsight <-> Microsoft Agent
Framework integration announcement card.

Signed-off-by: Ben <[email protected]>
2026-06-18 11:00:35 -04:00
Ben fe78692be3 blog(agent-framework): clickbaitier title + prose polish
Retitle to 'Stop Building Microsoft Agent Framework Agents That Forget'
(command-verb hook, slug/image unchanged). Tighten the intro's second
paragraph and sharpen the no-feedback-loop note to cite the regression
test by what it is.

Signed-off-by: Ben <[email protected]>
2026-06-18 10:25:23 -04:00
BenandClaude Opus 4.8 0b6eabad85 blog(agent-framework): add Microsoft Agent Framework persistent memory post
Walkthrough of the Hindsight context-provider integration for Microsoft
Agent Framework: HindsightProvider on before_run (recall → inject a
## Memories block) and after_run (retain). No MCP, no model tool-calling;
deterministic recall, per-user banks via bank_id, best-effort (never
blocks the agent), and a no-feedback-loop guard. Covers Cloud + self-host
setup and the config options.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-18 10:01:02 -04:00
Nicolò Boschi a7f82453a3 test(retain): serialize multichunk sub-batch coverage test on worker_tests xdist group (#2272)
* test(retain): serialize multichunk sub-batch coverage test on worker_tests xdist group

test_subbatch_multichunk_coverage.py's async case submits via
submit_async_retain, which inserts parent/child rows into async_operations.
test_worker.py drives its own WorkerPoller.claim_batch() against the same pool,
so on different xdist workers the two files steal each other's pending rows.
Add the shared xdist_group("worker_tests") marker (matching
test_async_batch_retain.py and the other async-queue tests) so they serialize
on one xdist process. Follow-up to #2269.

* test(worker): scope claim_batch count assertions to the test's own bank

The xdist_group("worker_tests") marker only serializes the tagged
async-queue test files among themselves. It cannot stop test_retain.py
(not tagged) from scheduling a 'consolidation' async_operation in the
public schema while a worker poller test runs — WorkerPoller.claim_batch()
scans the whole schema, so that stray op gets claimed and the global
'assert len(claimed) == N' counts it (observed: assert 3 == 2 in
test_poller_discovers_tenants_dynamically).

Filter claimed tasks to the test's own bank_id before counting, matching
the existing 'my_claims' convention already used by ~10 tests in this
file. Covers the remaining global-count assertions in the public-schema
poller tests; the max_slots cap test and the isolated custom-schema test
are unaffected (their global counts are robust by construction).
2026-06-18 12:06:12 +02:00
Nicolò Boschi 2bd6be8d85 docs: changelog and blog post for v0.8.3 (#2290)
* docs: changelog and blog post for v0.8.3

* docs: drop Richer MCP Tools section from 0.8.3 blog
2026-06-18 11:31:33 +02:00
Nicolò Boschi e1014cc790 Release v0.8.3
- Update version to 0.8.3 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.8
2026-06-18 11:13:37 +02:00
Kuba OdiasandClaude Opus 4.8 da2125cf13 feat(metrics): instrument async worker completion path with operation metrics (#2253)
* Instrument async worker completion path with operation metrics

The async worker never emitted hindsight_operation_operations_total /
_duration_seconds — record_operation() was only called from the synchronous
API layer. In prod, retain/reflect/consolidation run through the async worker,
so the Operations dashboard showed no retain activity and there was no
Prometheus signal for async throughput, latency or success/failure.

Emit operation metrics from the worker on terminal outcomes:
- Add MetricsCollector.record_operation_result(): direct (non-context-manager)
  recording with an explicit success label, for paths that need success control
  rather than the exception-based record_operation() CM. The CM now delegates
  to it (no behaviour change, no duplication).
- In WorkerPoller._execute_task_inner, record source="worker" with success=true
  on normal completion and success=false on failure. Deferrals (DeferOperation)
  and retries (RetryTaskAt) are not terminal and are deliberately not counted.
- Normalise the retain operation_type variants (batch_retain,
  file_convert_retain) onto operation="retain" so worker completions share the
  API path's series, which the Operations dashboard keys off.

This makes async retain visible on the dashboard and gives a Prometheus signal
for async worker throughput and success/failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Harden worker metric: record outside executor scope + cover defer/retry

W1: recording the success metric inside the executor try meant a metrics
failure could be caught by the broad except Exception and mark a completed
task as failed. Record on terminal outcomes outside the exception scope and
guard the call so instrumentation can never flip terminal task state.

W2: add no-DB tests for _execute_task_inner asserting completion/failure emit
the metric (with success true/false, retain normalised) and that
DeferOperation/RetryTaskAt do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* style: apply ruff format

Satisfy verify-generated-files: blank line after _metric_operation_label and
single-line record_operation_result test call, per ruff format.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* docs: correct reflect coverage in worker metric comment

reflect runs only on the synchronous API path (execute_task has no reflect
branch), so operation="reflect" never emits with source="worker". Reword the
comment to list retain/consolidation and the other worker task types instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* metrics(worker): scope success label to completion-throughput, not failure-rate

Address review: the worker success label infers success from raise/no-raise, but
memory_engine.execute_task swallows deterministic failures (file_convert_retain,
non-retryable errors) — it marks the op failed and returns normally — so those
record success=true. Rather than re-engineer execute_task to thread status back,
narrow this metric's documented meaning to a completion-throughput signal and
defer authoritative failure visibility to the now-merged
hindsight_async_operations{status="failed"} gauge (#1987), which reads each
operation's final DB status.

- Reword the poller comment: success=false means the task raised to the poller
  (unexpected / retry-exhausted); deterministic self-handled failures are not
  captured here — point operators at the failed gauge.
- Add test_executor_self_handled_failure_records_success_by_design to lock the
  intentional behavior so any future change to the inference is deliberate.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* test(worker): fold self-handled-failure case into the completion test

The separate test_executor_self_handled_failure_records_success_by_design
asserted nothing the completion test didn't: at the poller boundary a
self-handled failure is indistinguishable from a clean completion (both return
normally), and with the executor mocked there is no real mark-failed / DB status
to observe. Remove the duplicate and document the intentional scoping in the
renamed test_executor_returning_normally_records_success docstring instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-18 11:05:58 +02:00
Sanderhoff-alt f4bac2d41d fix(mcp): restore custom tool instructions (#2288)
Restore HINDSIGHT_API_MCP_INSTRUCTIONS for HTTP MCP servers.
Append the extra guidance only to retain and recall tool
descriptions, matching the original local MCP behavior without
changing reflect or other management tools.
2026-06-18 10:40:58 +02:00
Sanderhoff-alt 39abf0ad3f fix(tests): wait for testcontainers port mappings (#2283) 2026-06-18 10:35:37 +02:00
Sanderhoff-alt 8426b0c359 fix(tests): isolate backsweep migration pg0 state (#2282) 2026-06-18 10:33:26 +02:00
Derek Bouius f4a0a31f70 chore(deps): fix critical/high Dependabot alerts (#2278)
Resolve all 50 fixable critical/high Dependabot alerts across the monorepo.

Python (uv.lock):
- starlette 1.0.1 -> 1.3.1, python-multipart -> 0.0.32, pyjwt -> 2.13.0,
  tornado -> 6.5.7, urllib3 -> 2.7.0 across root + integration projects.
- cryptography -> 49.0.0 (GHSA-537c-gmf6-5ccf, bundled-OpenSSL OOB read).
  Lifted the hindsight-api-slim <47 cap: 47/48/49 verified importing and
  running RSA sign/verify cleanly on linux/arm64 (Docker on Apple Silicon)
  and native arm64 macOS; the SIGILL of pyca/cryptography#14733 does not
  reproduce on current tooling (upstream issue closed unconfirmed).
- Root and haystack uv.lock pick up uv lockfile revision 3 (the format the
  rest of the repo's locks and CI's setup-uv@v7 already use).

npm:
- shell-quote -> 1.8.4 (critical); ws -> 7.5.11 / 8.21.0; vite -> 8.0.16
  across root + integrations; embed control-center UI vite ^5 -> ^6.4.3
  (build verified); n8n form-data override -> ^4.0.6.
- zapier: overrides for form-data, serialize-javascript, tar, tmp,
  yeoman-environment (dev-only zapier-platform-cli tree); npm audit clean.

Not fixed (no safe path):
- nltk (llamaindex, pipecat): no patched release exists upstream (<=3.9.4).
- pipecat-ai (pipecat): fix needs 1.2.0 but the integration is pinned <1.0
  pending a module-restructure migration.
2026-06-18 09:32:25 +02:00
DK09876 65862c4fef feat(openhands): add OpenHands integration (native MCP config + recall/retain rule) (#2276)
Long-term memory for OpenHands via native Streamable-HTTP MCP: hindsight-openhands init wires the Hindsight MCP server into config.toml + a recall/retain rule in AGENTS.md.
2026-06-17 12:46:20 -07:00
Ben ef548833fd blog(freshness): Freshness-Aware Memory — knowing when a belief has gone stale (#2267)
* blog(freshness): add freshness-aware memory post

Concept deep-dive on how Hindsight tracks belief currency: the per-observation
freshness trend (new/strengthening/stable/weakening/stale, computed from evidence
timestamps over 30/90-day windows by density ratio) and the consolidation-lag
signal (up_to_date/slightly_stale/stale from pending memories), plus how the
reflect loop uses both to verify stale beliefs against raw facts.
2026-06-17 15:28:46 -04:00
DK09876andClaude Opus 4.8 55f70e1d27 fix(docs): make integrations.json strict-valid (drop trailing comma)
The last entry had a trailing comma, so build-docs' 'Check integrations'
step (strict JSON.parse) failed on main and every PR. Drop it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 10:33:03 -07:00
DK09876 b8cfddd7b6 release(zed): v0.1.0 2026-06-17 10:30:00 -07:00
DK09876andClaude Opus 4.8 52cb9a2bae chore(dev): register continue/zed/openhands in changelog generator
These new integrations were added to VALID_INTEGRATIONS / CI but not to the
generate-changelog registry, so release-integration.sh failed at the changelog
step. Add their package names so releases can be cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 10:29:53 -07:00
DK09876 539101af38 feat(zed): add Zed editor integration (MCP context server + recall/retain rule) (#2153)
MCP-only Zed integration: hindsight-zed init wires the Hindsight MCP server into Zed's settings.json (via mcp-remote) plus a recall/retain rule in AGENTS.md. Validated end-to-end in real Zed.
2026-06-17 10:28:07 -07:00
DK09876 efa37cb15f release(opencode): v0.2.6 2026-06-17 08:57:36 -07:00
BenandClaude Opus 4.8 faaa97d4a0 docs(observations): stop claiming a per-observation freshness trend (#2271)
The "computed freshness trend (stable/strengthening/weakening/new/stale)"
described across the developer docs maps to code in
reflect/observations.py that is unreferenced — not wired into recall,
reflect, or the API, and absent from the OpenAPI schema. It is not a
surfaced feature, so the docs overstated it.

Replace those claims with the freshness behavior that IS shipped: when
newer memories haven't been consolidated yet, reflect treats the affected
observations as stale and verifies them against raw facts. Touches
developer/index, observations, configuration, api/recall, and
best-practices, plus the regenerated skills/hindsight-docs mirror.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 17:28:35 +02:00
yugandhar-maram 70804fe2c6 Update integrations.json (#2268)
Add agrasandhany integration
2026-06-17 17:28:12 +02:00
Parafee41 ae2532b165 Hide Windows netstat port probes (#2263) 2026-06-17 17:26:18 +02:00
Nicolò Boschi 81865bf873 fix(retain): stop dropping chunks when an oversized doc splits into multi-chunk sub-batches (#2269)
Ingesting a large single document (~88k chars) dropped most of its body — and
any fact past the first slice — when retained. Two bugs, both only triggered when
an oversized item is split into sequential sub-batches whose slices each re-chunk
into several extraction chunks (the default config: batch tokens 10k → ~30k-char
slices, re-chunked at 3k → ~10 chunks/slice):

1. chunk_index offset (sync + async). retain_batch_async advanced the
   per-document chunk_index cursor by re-chunking item["content"] AFTER the
   orchestrator had consumed (popped) it. chunk_text("") returns [""] (count 1),
   so the cursor moved by 1 per sub-batch instead of by the real chunk count;
   later slices restarted ~1 slot in, colliding chunk_id = {bank}_{doc}_{index}
   and overwriting earlier chunks via upsert. Fix: count the slice's chunks
   before handing it to the orchestrator, while content is still present.

2. whole-document recovery skip (async only). All sub-batches of one submitted
   operation share one operation_id; the first slice stamps the document into
   result_metadata.facts_committed_document_ids. The crash-recovery fast-path
   then saw every later slice's document already "committed" and skipped
   extraction entirely, so only the first slice survived. Fix: only take the
   whole-document skip when the call starts the document at chunk 0
   (chunk_index_offset == 0); a non-zero offset means this call continues a
   document another sub-batch already started. Per-chunk hash recovery
   (existing_chunk_hashes) still provides crash-safety for those chunks.

The existing #1888 coverage tests use RETAIN_BATCH_TOKENS=100 (a ~300-char
budget, under the chunk size) so every slice collapses to ONE chunk, which masks
both bugs. New test_subbatch_multichunk_coverage.py sizes the body so each slice
fans out to ~6 chunks, with globally-unique tokens (no chunk-hash dedup), and
asserts full coverage + contiguous chunk_index + a needle planted in a late slice
across BOTH the sync (retain_batch_async) and async (submit_async_retain) paths.
2026-06-17 17:25:09 +02:00
Nicolò Boschi 9e47759347 test(retain): add retain_structured_chunk_size to quota-defer test config mock (#2265)
extract_facts_from_text reads config.retain_structured_chunk_size (passed
to chunk_text), but the test's SimpleNamespace mock only set
retain_chunk_size, so the test raised AttributeError instead of exercising
the quota-defer path. Add the field (None = plain chunking) to fix it.
2026-06-17 15:58:26 +02:00
Nicolò Boschi d8665d7ab0 test(openclaw): update agent_end hook tests for stripped context system message (#2266)
#1968 moved routing metadata out of the transcript (it no longer prepends a
'[context]' system message) and into the retain API context field, but left
three agent_end integration assertions on the old shape:
- transcript no longer starts with a {role:'system', '[context]...'} entry
- message_count reflects the structured turn length without the system pad
  (1 for a single-user turn, 2 for the last user+assistant turn)

Updates index.test.ts's sibling integration tests to match.
2026-06-17 15:40:32 +02:00
Evo 9bde15331e docs: document MCP trace and precheck content length (#2264) 2026-06-17 15:19:20 +02:00
Nicolò BoschiandSveinbjörn Geirsson 2fb2de1aa8 feat(embeddings): detect Intel XPU for local embedding acceleration (#2260)
Extend local device detection so sentence-transformers can use an Intel
XPU (e.g. Arc A770) when a torch XPU build is loaded, falling back to CPU
otherwise. Split out from #2233.

Co-authored-by: Sveinbjörn Geirsson <raudbjorn@github>
2026-06-17 14:43:57 +02:00
Evoandr266-tech d68bd07423 Add Gemini service tier config (#2251)
* Add Gemini service tier config

* Format generated Gemini service tier files

---------

Co-authored-by: r266-tech <[email protected]>
2026-06-17 14:37:26 +02:00
Yunan Wang 44972d3215 fix(mcp): omit reflect tool_trace/llm_trace from responses by default (#2242)
The MCP `reflect` tool returned the full `reflect_async` result, which
includes `tool_trace` and `llm_trace` — the entire internal agent loop,
including full mental-model text. A default reflect response measured
59,657 chars (text 5,987 + tool_trace 52,711), silently consuming tens
of KB of MCP-client context on every call, while the REST API omits the
trace by default.

Add a symmetric `include_trace: bool = False` flag (mirroring the
existing `include_based_on`); the trace becomes opt-in for debugging.
Applied to both the multi-bank and single-bank reflect registrations,
with a regression test covering both.
2026-06-17 12:32:39 +02:00
de1tyandNicolò Boschi aa308ad201 fix(openclaw): strip runtime metadata from memory content (#1968)
* feat(openclaw): pass retain context guidance to prevent routing metadata misattribution

Hindsight's fact extraction LLM was misinterpreting routing identifiers
(sender open_id, bank ID, channel, provider) as semantic actors, project
names, or organizations. After many conversation turns, the bank name
(e.g. saber-prod) would override the actual project being discussed
(e.g. x-power-cli).

This adds interpretation guidance via the retain API 'context' field:
- New DEFAULT_RETAIN_CONTEXT constant explains that [context] block
  sender/channel/provider are routing identifiers, not human names
- Bank IDs, session keys, agent IDs, thread IDs, and tags are also
  marked as operational routing identifiers, not project names
- Assistant-role first-person statements are attributed to the AI
- Context is passed through the full chain: buildRetainRequest →
  scopeClient.retain → Hindsight SDK API
- RetainQueue persists and flushes context correctly
- Backfill CLI also passes context
- New 'retainContext' config option allows customization

includeSenderContext behavior is unchanged; the [context] block remains
in transcript content, but extraction LLM now knows how to interpret it.

7 files changed, 97 insertions(+).

* fix(openclaw): remove platform-specific examples from DEFAULT_RETAIN_CONTEXT

* test(openclaw): harden retain context handling

* fix(openclaw): strip runtime metadata from memory content

* refactor(openclaw): remove dead session-context surface

Following the removal of transcript context-prepending, drop the now-unused
formatRetentionSessionContext / RetentionSessionContext and the ignored
prepareRetentionTranscript session-context parameter (and the discarded
object built at the live call site). Remove the inert includeSenderContext
config option (no longer read) from the type, manifest schema, and UI label.
Collapse the session-context tests to two regression guards asserting that
retained JSON/text content carries no context header.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 12:29:22 +02:00
Timur KhairutdinovandTimur Khairutdinov cb73790c27 fix(api): apply bank-config disposition + mission overlay in list_banks (#2101)
list_banks now overlays resolved bank config (reflect_mission + disposition_*) on top of the legacy banks.disposition/banks.mission columns, matching get_bank_profile so the list and get paths agree for a bank.

Overlay extracted into a shared helper returning a ResolvedDispositionMission dataclass. Config is resolved in one batch (single banks.config query + one tenant resolve) via ConfigResolver.get_bank_configs(), avoiding an N+1 of per-bank config resolves.

Co-authored-by: Timur Khairutdinov <[email protected]>
2026-06-17 12:27:39 +02:00
Matthew JacksonandNicolò Boschi b1fe23fbe4 feat(metrics): expose async-operation queue + consolidation backlog as gauges (#1987)
* feat(metrics): expose async-operation queue + consolidation backlog as gauges

The bank-stats endpoint already computes operations_by_status,
pending_consolidation and failed_consolidation, but only as a point-in-time
HTTP response per bank. There's no way to trend or alert on "is the worker
keeping up?" / "is the knowledge base caught up?" from Prometheus.

This adds three observable gauges, fed by a 30s background-refresh cache (the
same pattern as the existing db-pool gauges, so the /metrics scrape path stays
synchronous):

- hindsight_async_operations{operation_type,status} -- worker queue depth for
  non-terminal states. pending = queued backlog (e.g. retain / consolidation),
  processing = in-flight, failed = stranded. Terminal states (completed,
  cancelled) are deliberately excluded: a gauge of finished work grows without
  bound and says nothing about current load. The processing series is the only
  signal that surfaces a hung operation holding a worker slot.
- hindsight_consolidation_backlog -- source memories (experience/world) not yet
  consolidated into observations (pending_consolidation).
- hindsight_consolidation_failed -- source memories whose consolidation
  permanently failed, recoverable via the consolidation recovery endpoint
  (failed_consolidation).

The SQL is lifted from the bank-stats endpoint and is index-backed
(idx_async_operations_status, idx_memory_units_unconsolidated). Per-bank labels
are gated behind the existing metrics_include_bank_id flag (off by default);
when off, counts aggregate per tenant/schema, bounding cardinality to a handful
of series. All queries are PostgreSQL-specific (FILTER, information_schema),
consistent with this collector already being bound to an asyncpg pool.

* review: address feedback on backlog metrics

- Split the consolidation backlog into two separate COUNT(*) queries, each with
  a WHERE matching a partial-index predicate exactly (idx_memory_units_
  unconsolidated / idx_memory_units_consolidation_failed), instead of one
  aggregate with two FILTERs that seq-scans the whole memory_units table on
  every 30s refresh across every schema. GROUP BY bank_id still composes
  (bank_id is each index's lead column).
- Type the gauge cache keys as NamedTuples (_AsyncOpKey, _BacklogKey) instead of
  raw tuples.
- Hoist `import asyncio` to module scope (was imported inside two methods).
- Document that _backlog_task is process-lifetime and intentionally not
  cancelled (no teardown hook to hang it on).
- Add tests for the per_bank=True path (bank_id in the cache key + GROUP BY
  bank_id in the SQL + bank_id gauge attribute) and assert the backlog queries
  are index-matched, not FILTER scans.

* fix(metrics): force index scan for the consolidation backlog count

Splitting the consolidation count into two index-predicate-matched COUNT(*)
queries fixed the failed count (index-only scan) but NOT the backlog count.
Verified on a 114k-row memory_units via EXPLAIN ANALYZE: the backlog query still
seq-scans (~92 ms) because `consolidated_at IS NULL` is true for ~40% of the
table (every observation has a null consolidated_at), so the planner misjudges
selectivity and won't use idx_memory_units_unconsolidated even though the
predicate matches it exactly. ANALYZE doesn't change the plan (structural, not
stale stats); `enable_seqscan=off` confirms the index is usable (~0.1 ms).

Run the backlog count in a scoped transaction with SET LOCAL enable_seqscan=off
to force the partial-index scan (verified ~0.07 ms, transaction-scoped, no
leak). The failed count needs no nudge — consolidation_failed_at IS NOT NULL is
rare, so its index is chosen on cost.

* feat(metrics): gate consolidation backlog gauges behind config flag (off by default)

Add HINDSIGHT_API_METRICS_BACKLOG_ENABLED (default false). The
async-operation queue + consolidation backlog gauges run periodic
per-schema COUNT queries on a background task, so they are now opt-in
rather than always-on when a db pool is set.

* chore: sync embed env template + prettify paperclip README after main merge

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 12:27:28 +02:00
Chris Bartholomew ce81217381 feat(extensions): expose Content-Length on PrecheckContext (#2247)
Add an optional `content_length: int | None` field to `PrecheckContext`
and populate it from the request's `Content-Length` header in the
`_precheck_dep` FastAPI dependency wired by the billable POST routes.

Surfacing the header lets a precheck make size-aware decisions — for
example, computing an upper-bound cost estimate (`bytes / tokens-per-byte
* per-op-rate`) and rejecting before the body is read or deserialised —
without changing the contract that the precheck runs before body parse.

The field is optional with a default of `None`, so existing
`OperationValidatorExtension` implementations and `PrecheckContext`
construction sites are unaffected. `None` also remains the value when
the header is absent (e.g. chunked transfer encoding) or unparseable;
`0` is preserved as a known empty body.

Adds three tests in `TestPrecheckHttpWiring`:
- header populated → validator sees the int
- empty POST body → validator sees `0`, not `None`
- header missing → validator sees `None`
2026-06-17 12:23:38 +02:00
haodonp 94619ce52b fix(consolidation): handle single-value source_fact_ids from LLM (#2240)
Some LLMs return source_fact_ids as a string instead of a list when there is only one source ID. Add field_validator on both  _CreateAction and _UpdateAction to auto-wrap into a single-element list.

Relates-to: #1656
2026-06-17 12:22:53 +02:00
Yunan WangandNicolò Boschi 27aa6bbf46 feat(mcp): add ToolAnnotations (read-only/destructive hints) to MCP tools (#2243)
* feat(mcp): add ToolAnnotations (read-only/destructive hints) to MCP tools

All MCP tools registered with bare @mcp.tool() and exposed no annotations,
so clients (claude.ai, Notion, …) could not group read vs write tools,
surface a destructive-action warning for delete_bank / clear_memories, or
auto-approve safe reads.

Add a _tool_annotations() helper that classifies each tool as read-only,
destructive, or plain write, and apply it to every registration.
openWorldHint=False throughout (closed memory store). Pure metadata — no
behavioural change.

reflect is classified as a (non-destructive) write because it can form and
persist opinions during synthesis; flip it to readOnlyHint=True if the
engine never persists on reflect.

* fix(mcp): classify reflect as read-only (engine persists nothing)

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 12:19:41 +02:00
Chris Bartholomew acf4d5c860 log(consolidation): show call count + avg for each timing phase (#2238)
The consolidation summary log used to print only the total time per phase:

    [4] Timing breakdown: recall=15.425s, llm=43.181s, embedding=0.301s

This makes it easy to misread the "recall=15s" line as a single slow query
when it is actually the sum of many sequential sub-calls (e.g. 100 internal
recalls at ~150ms each). Add a call counter to ConsolidationPerfLog and
include both the count and a per-call average when count > 1:

    [4] Timing breakdown: recall=15.425s (100 calls, avg=154ms),
                          llm=43.181s (12 calls, avg=3598ms),
                          embedding=0.301s (3 calls, avg=100ms),
                          db_write=0.829s

Operators triaging "the recall phase took 15s" can now tell at a glance
whether the cost is one slow query or many fast ones, which leads to very
different diagnostic paths. Single-call timings keep the existing terse
format (no `(1 calls, ...)` clutter).

Backward-compatible: timing_counts is a new attribute; existing accessors
on `timings` and `llm_calls` keep their current semantics.
2026-06-17 12:17:27 +02:00
Kuba OdiasandClaude Opus 4.8 551932991d fix(litellm): hard-cap completions with asyncio.wait_for so a hung call can't block forever (#2224)
* fix(litellm): cap completions with asyncio.wait_for so a hung call can't block forever

The LiteLLM provider issued completions as a bare `await self._acompletion(...)`.
The only timeout was the `timeout=` kwarg handed to `litellm.acompletion()`,
which is not always honored (e.g. a connection held open with no token
progress). When that happens the coroutine awaits indefinitely, holding the
worker slot and a concurrency-semaphore permit for the lifetime of the process.

Fact extraction fans these calls out through `asyncio.gather`, so a single
hung straggler stalls the whole operation even though its sibling calls
returned — completion throughput collapses to zero while sibling calls keep
succeeding, which makes the failure mode hard to diagnose.

Wrap the request in `asyncio.wait_for(timeout=self.timeout)` in both `call`
and `call_with_tools` (mirroring the Gemini provider, which already does this)
and treat the resulting `TimeoutError` as a normal retryable attempt, so the
task can retry or fail cleanly and release its slot. The existing
`asyncio.gather(..., return_exceptions=True)` callers absorb the timeout with
no extra handling.

Also thread an optional `timeout` through `create_llm_provider` and
`LLMConfigWrapper` into the LiteLLM/Bedrock/Router providers so the cap is
configurable; `None` keeps the existing 300s default (never `None`, which
would make `wait_for` wait forever).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(litellm): make the hard-timeout cap configurable and converge timeout handling

Builds on the asyncio.wait_for cap added for the LiteLLM family:

- Wire LLMProvider.from_env() to read HINDSIGHT_API_LLM_TIMEOUT (default
  DEFAULT_LLM_TIMEOUT = 120s). The cap was threaded through the constructors but
  never set by from_env(), so it silently defaulted to 300s and was not
  configurable. This matches how the OpenAI-compatible provider already reads the
  same var. Also fix the stale openai-compatible docstring that claimed 300s.

- Converge timeout handling: litellm's own Timeout and the outer wait_for
  TimeoutError are armed at the same deadline but previously flowed through
  different except blocks (generic vs dedicated), so which one tripped was a race
  producing different log lines and backoff. Catch both in one block so they
  share a retry policy and log line; log the exception class name so the firing
  mechanism stays visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* refactor(litellm): hoist litellm Timeout import to module level

Address PR review (r3421670469): litellm is a hard dependency already imported
in __init__, so the per-call function-local `from litellm.exceptions import
Timeout` in call/call_with_tools is unnecessary. Hoist to a module-level import,
matching how gemini_llm imports its SDK.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 12:08:49 +02:00
Sanderhoff-alt 5ee53c512f feat(api): add optional MarkItDown OCR support (#2145)
MarkItDown advertises image extensions, but without OCR config it can
fail screenshots or scanned images with low-level no-content errors.

Add server-level MarkItDown OCR config that is off by default and
independent from HINDSIGHT_API_LLM_*. When OCR is enabled, the OCR API
key, base URL, and model are required explicitly.

Wire those settings into MarkItDown's llm_client support with a built-in
OCR prompt. Image uploads now fail fast with actionable errors when OCR
is disabled or required settings are missing.

Docs and front-end copy explain that image OCR depends on server config
and requires an OpenAI-compatible OCR/vision endpoint.

Closes #927
2026-06-17 12:07:58 +02:00
Justas ŠireikaandClaude Opus 4.8 4efa204727 fix: template bank-id path segment in HTTP metric endpoint label (#2191)
http_metrics_middleware normalizes only UUID and pure-numeric path
segments, so non-numeric bank ids (e.g. user-123, tenant-acme) survive in
the /banks/<id> segment of the `endpoint` metric label. Each distinct bank
then becomes a never-evicted OTel series, growing process memory unboundedly
on every per-bank request.

Same unbounded-OTel-cardinality class as #850 (fixed in #898 for the
record_operation bank_id attribute), via a code path #898 did not cover.

Extract endpoint normalization into a pure, unit-tested normalize_http_endpoint()
helper in metrics.py (next to get_token_bucket) that also templates the
/banks/<id> segment, and call it from the middleware.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-17 12:02:04 +02:00
EABandClaude Fable 5 c3bb647640 claude-code: case-insensitive directoryBankMap matching on Windows (#2183)
derive_bank_id compared os.path.normpath(cwd) against the map keys with
==, which is case-sensitive — but on Windows the drive-letter case of the
cwd a session reports depends on the launcher: PowerShell and git-bash
hand child processes an UPPERCASE drive (C:\...) while the VS Code
extension spawn reports lowercase (c:\...). cmd.exe preserves whatever
case was typed. A directoryBankMap entry can therefore silently miss for
some launchers and fall through to the default bank, with no error —
sessions quietly land in the wrong memory bank.

Fix: wrap both sides in os.path.normcase, which lowercases and normalizes
separators on Windows and is a documented no-op on POSIX — so POSIX path
matching stays case-sensitive (pinned by a new test) and Windows matching
becomes launcher-independent (pinned by a new test that fails without
this change).

Co-authored-by: Claude Fable 5 <[email protected]>
2026-06-17 12:00:54 +02:00
Eldar ShlomiandClaude Opus 4.8 12851bc7ee fix(claude-code-mcp): resolve venv interpreter in Windows Scripts/ layout (#2066)
run_mcp.sh's resolve_py() probed only <venv>/bin/python and
<venv>/bin/python.exe. A standard Windows CPython venv (python.org
installer, Windows Store Python, `py -m venv`) puts the interpreter at
<venv>/Scripts/python.exe, so resolve_py returned empty, the launcher
fell through to venv re-creation, and re-creation failed whenever
python/python3 were not on the spawning process's PATH (issue #1758, 3a).

Add a Scripts/ elif branch and update the now-misleading "venv create
failed" message to mention both layouts. POSIX behaviour is unchanged.

Adds a hermetic pytest that invokes the real bash resolve_py against a
fabricated venv tree: RED on the Scripts/ layout before this change,
plus a bin/ regression guard for the POSIX path.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-17 11:58:45 +02:00
Nicolò Boschi a5f4d30ea6 fix(openclaw): always session-scope retained documents (#2259)
retainDocumentScope was always meant to be 'session'; the 'turn' option
just disabled document accumulation. Remove the config field entirely so
retains always use a stable per-session document id (falling back to
per-turn ids only on legacy APIs that lack update_mode: 'append').
2026-06-17 11:55:51 +02:00
Misha DenilandNicolò Boschi 0c9bc765ce Honor CODEX_HOME for Codex auth.json location (#1874)
Codex authentication previously hardcoded ~/.codex/auth.json in several
places. Route all Codex auth/LLM/embeddings paths through a single
default_codex_auth_file() helper that honors the CODEX_HOME environment
variable (matching the upstream @openai/codex CLI), falling back to
~/.codex when unset or empty.

Adds tests for the resolution logic and hardens an existing embeddings
test against CODEX_HOME leaking in from the environment.

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-17 11:45:01 +02:00
Nicolò Boschi cd34efa596 chore(ci): disable Dependabot version updates (#2257)
Remove .github/dependabot.yml to stop Dependabot from opening
automated version-update PRs (github-actions ecosystem).

Note: Dependabot security updates are controlled by a repository
setting, not this file, and must be disabled separately in repo
settings if desired.
2026-06-17 11:44:15 +02:00
formatme 2b521c3a09 fix(api): defer provider quota reset retries (#2194) 2026-06-17 11:43:52 +02:00
Nicolò Boschi 9681d96195 Add Python client get_version helper (#2256)
Adds HindsightClient.get_version()/aget_version() convenience wrappers for
the existing /version endpoint, re-exports VersionResponse for typed callers,
and tests both paths against a mocked MonitoringApi.

Python parity for #2252 (TypeScript getVersion). Fixes #2248.
2026-06-17 11:38:15 +02:00
Evo a32ecfeb33 docs(paperclip): document dynamicBankId / bankId / user granularity (#1761) (#1803)
* docs(paperclip): document dynamicBankId / bankId / user granularity

* docs(paperclip): mirror dynamicBankId / bankId / user granularity in integration README
2026-06-17 11:30:26 +02:00
Evo bf73a1dfbe docs(embed): document control center commands (#2151) 2026-06-17 11:28:41 +02:00
Evo ca2ce5c16d fix(api): reject empty/whitespace content in dry-run extraction before the LLM call (#2246)
* fix(api): reject empty/whitespace content in dry-run extraction before the LLM call

* test(api): assert dry-run extraction rejects empty content (422)
2026-06-17 11:25:58 +02:00
grimmjoww578andClaude Opus 4.8 7b17da7a0c Strip reasoning tags on non-structured output + unclosed blocks (#2195)
The reasoning-tag strip in OpenAICompatibleLLM.call() only ran inside the
`if response_format is not None:` (structured/JSON) branch. The `else:` branch
that returns free-form, non-structured output (e.g. consolidated mental-model
markdown) returned the raw provider content with no strip at all. Reasoning
models that emit their chain-of-thought in the response body — confirmed with
MiniMax-M3 — therefore leaked `<think>...</think>` verbatim into stored mental
models.

Additionally, every existing strip regex used the lazy `<tag>.*?</tag>` form,
which requires a closing tag. When output is truncated mid-thought the closing
tag never arrives, so a dangling `<think>` slipped through even on the JSON path.

Fix:
- Factor a module-level `_strip_reasoning_tags(text)` helper covering the full
  tag set (think, thinking, thought, reasoning, |startthink|...|endthink|).
- For each tag, strip closed blocks (`<tag>...</tag>`, DOTALL) and then any
  remaining unclosed block (`<tag>.*` to end-of-string).
- Call it from BOTH branches: the structured path (replacing the inline regex
  block) and the free-form path (which previously had no strip).

Adds tests/test_strip_reasoning_tags.py covering closed/unclosed blocks, all
tag styles, multi-line and multi-block input, and the real-world mental-model
markdown contamination case.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-17 11:25:34 +02:00
496 changed files with 13847 additions and 6305 deletions
+16
View File
@@ -87,6 +87,18 @@ HINDSIGHT_API_LOG_LEVEL=info
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
# Enable image OCR for MarkItDown using an OpenAI-compatible OCR/vision endpoint.
# These OCR settings are independent from HINDSIGHT_API_LLM_* because MarkItDown
# uses the OpenAI SDK directly and requires Chat Completions image input support.
# When OCR is enabled, API_KEY, BASE_URL, and MODEL are required.
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=false
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT=
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
@@ -150,6 +162,10 @@ HINDSIGHT_API_LOG_LEVEL=info
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
#
# Expose async-operation queue + consolidation-backlog gauges on /metrics.
# Runs periodic per-schema COUNT queries on a background task (disabled by default).
# HINDSIGHT_API_METRICS_BACKLOG_ENABLED=true
# -----------------------------------------------------------------------------
# Control Plane (Optional)
-6
View File
@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+77
View File
@@ -50,12 +50,14 @@ jobs:
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }}
integrations-zed: ${{ steps.filter.outputs.integrations-zed }}
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 }}
integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }}
integrations-openhands: ${{ steps.filter.outputs.integrations-openhands }}
integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }}
integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }}
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
@@ -166,6 +168,8 @@ jobs:
- 'hindsight-integrations/opencode/**'
integrations-cursor:
- 'hindsight-integrations/cursor/**'
integrations-zed:
- 'hindsight-integrations/zed/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
@@ -180,6 +184,8 @@ jobs:
- 'scripts/check-integration-lockfiles.sh'
integrations-openai-agents:
- 'hindsight-integrations/openai-agents/**'
integrations-openhands:
- 'hindsight-integrations/openhands/**'
integrations-pipecat:
- 'hindsight-integrations/pipecat/**'
integrations-agentcore:
@@ -488,6 +494,37 @@ jobs:
working-directory: ./hindsight-integrations/cursor
run: python -m pytest tests/ -v
test-zed-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zed == '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 package and pytest
working-directory: ./hindsight-integrations/zed
# Installs the package (incl. the zstandard runtime dep) so the threads.db
# reader tests can decompress Zed's zstd blobs.
run: pip install -e . pytest
- name: Run tests
working-directory: ./hindsight-integrations/zed
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: python -m pytest tests/ -v -m "not requires_real_llm"
test-omo-integration:
needs: [detect-changes]
if: >-
@@ -3669,6 +3706,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-openhands-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openhands == '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 openhands integration
working-directory: ./hindsight-integrations/openhands
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/openhands
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/openhands
# 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-claude-agent-sdk-integration:
needs: [detect-changes]
if: >-
@@ -4717,6 +4793,7 @@ jobs:
- test-pydantic-ai-integration
- test-llamaindex-integration
- test-openai-agents-integration
- test-openhands-integration
- test-agentcore-integration
- test-haystack-integration
- test-pip-slim
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.2
appVersion: "0.8.2"
version: 0.8.3
appVersion: "0.8.3"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.2",
"version": "0.8.3",
"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",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.8.2"
version = "0.8.3"
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.2",
"hindsight-api-slim==0.8.3",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+3 -3
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.8.2"
version = "0.8.3"
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.2",
"hindsight-api-slim[all]==0.8.3",
"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.2",
"hindsight-api-slim[local-llm]==0.8.3",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.2"
__version__ = "0.8.3"
+28 -11
View File
@@ -158,7 +158,12 @@ from hindsight_api.engine.response_models import (
)
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
from hindsight_api.metrics import (
create_metrics_collector,
get_metrics_collector,
initialize_metrics,
normalize_http_endpoint,
)
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
@@ -1442,6 +1447,13 @@ class DryRunExtractRequest(BaseModel):
entities_allow_free_form: bool | None = None
llm_output_language: str | None = None
@field_validator("content")
@classmethod
def validate_content(cls, v: str) -> str:
if not v.strip():
raise ValueError("content cannot be empty")
return v
class ListDocumentsResponse(BaseModel):
"""Response model for list documents endpoint."""
@@ -3237,15 +3249,9 @@ def create_app(
@app.middleware("http")
async def http_metrics_middleware(request, call_next):
"""Record HTTP request metrics."""
# Normalize endpoint path to reduce cardinality
# Replace UUIDs and numeric IDs with placeholders
import re
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)
# Replace numeric IDs
path = re.sub(r"/\d+(?=/|$)", "/{id}", path)
# Template id segments (bank ids, UUIDs, numeric ids) so the endpoint
# metric label stays bounded-cardinality.
path = normalize_http_endpoint(request.url.path)
status_code = [500] # Default to 500, will be updated
metrics_collector = get_metrics_collector()
@@ -3333,6 +3339,7 @@ def _register_routes(app: FastAPI):
async def _precheck_dep(
bank_id: str,
request: Request,
request_context: RequestContext = Depends(get_request_context),
) -> None:
validator = getattr(app.state.memory, "_operation_validator", None)
@@ -3341,10 +3348,20 @@ def _register_routes(app: FastAPI):
from hindsight_api.extensions import PrecheckContext
await app.state.memory._authenticate_tenant(request_context)
cl_header = request.headers.get("content-length")
content_length: int | None = None
if cl_header is not None:
try:
parsed = int(cl_header)
except ValueError:
parsed = -1
if parsed >= 0:
content_length = parsed
ctx = PrecheckContext(
operation=operation,
bank_id=bank_id,
request_context=request_context,
content_length=content_length,
)
result = await validator.precheck(ctx)
if not result.allowed:
@@ -6749,7 +6766,7 @@ def _register_routes(app: FastAPI):
description="Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\n"
"This endpoint handles file upload, conversion, and memory creation in a single operation.\n\n"
"**Features:**\n"
"- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n"
"- Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription)\n"
"- Automatic file-to-markdown conversion using pluggable parsers\n"
"- Files stored in object storage (PostgreSQL by default, S3 for production)\n"
"- Each file becomes a separate document with optional metadata/tags\n"
+20 -1
View File
@@ -9,7 +9,7 @@ from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api import __version__ as HINDSIGHT_VERSION
from hindsight_api.config import _get_raw_config
from hindsight_api.config import DEFAULT_MCP_RECALL_DESCRIPTION, DEFAULT_MCP_RETAIN_DESCRIPTION, _get_raw_config
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
@@ -78,6 +78,19 @@ def get_current_mcp_authenticated() -> bool:
return _current_mcp_authenticated.get()
def _build_mcp_tool_descriptions(extra_instructions: str | None) -> tuple[str | None, str | None]:
"""Return custom retain/recall descriptions when server-level MCP instructions are set."""
if not isinstance(extra_instructions, str):
return None, None
extra_instructions = extra_instructions.strip()
if not extra_instructions:
return None, None
suffix = f"\n\nAdditional instructions: {extra_instructions}"
return DEFAULT_MCP_RETAIN_DESCRIPTION + suffix, DEFAULT_MCP_RECALL_DESCRIPTION + suffix
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -135,6 +148,10 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
allowed = frozenset(global_config.mcp_enabled_tools)
base_tools = (base_tools if base_tools is not None else _ALL_TOOLS) & allowed
retain_description, recall_description = _build_mcp_tool_descriptions(
getattr(global_config, "mcp_instructions", None)
)
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
@@ -144,6 +161,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
include_bank_id_param=multi_bank,
tools=base_tools,
retain_description=retain_description,
recall_description=recall_description,
)
register_mcp_tools(mcp, memory, config)
@@ -142,6 +142,7 @@ 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_GEMINI_SERVICE_TIER = "HINDSIGHT_API_LLM_GEMINI_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"
@@ -159,11 +160,25 @@ ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
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_GEMINI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper best-effort tier)
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
)
def parse_gemini_service_tier(value: str | None) -> str | None:
"""Normalize and validate the Gemini service tier."""
tier = value or None
valid_tiers = (None, "flex")
if tier not in valid_tiers:
raise ValueError(
f"Invalid HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER: "
f"{tier!r}. Must be one of: {', '.join(t for t in valid_tiers if t is not None)}."
)
return tier
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
@@ -354,6 +369,7 @@ ENV_ACCESS_LOG = "HINDSIGHT_API_ACCESS_LOG"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
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"
@@ -375,6 +391,7 @@ ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
ENV_METRICS_INCLUDE_BANK_ID = "HINDSIGHT_API_METRICS_INCLUDE_BANK_ID"
ENV_METRICS_BACKLOG_ENABLED = "HINDSIGHT_API_METRICS_BACKLOG_ENABLED"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
@@ -424,6 +441,11 @@ ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED"
ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY"
ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL"
ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL"
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_PARSER_LLAMA_PARSE_API_KEY = "HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY"
@@ -804,6 +826,7 @@ DEFAULT_ACCESS_LOG = False
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_MCP_INSTRUCTIONS = None
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
@@ -847,6 +870,10 @@ DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown")
DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers)
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED = False
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT = """You are a precise OCR transcription engine.
Transcribe only the visible text in the image. Do not describe the image, summarize it, translate it, infer missing content, or add commentary. Preserve the original language, wording, numbers, punctuation, capitalization, and reading order. Reconstruct headings, lists, key-value fields, stamps, and tables as clean Markdown when the layout is clear. If text is unreadable or uncertain, write [unclear] for that span. Return only the extracted Markdown."""
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
@@ -965,6 +992,7 @@ DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatib
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
DEFAULT_METRICS_INCLUDE_BANK_ID = False # Disabled by default to avoid high-cardinality OTel metric growth
DEFAULT_METRICS_BACKLOG_ENABLED = False # Disabled by default: runs periodic per-schema COUNT queries
# Audit log defaults
DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
@@ -1298,6 +1326,7 @@ class HindsightConfig:
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_gemini_service_tier: str | None # Gemini: None (default) or "flex" (50% cheaper)
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
@@ -1478,6 +1507,7 @@ class HindsightConfig:
mcp_enabled: bool
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)
mcp_instructions: str | None # Additional instructions appended to retain/recall MCP tool descriptions
enable_bank_config_api: bool
enable_bank_llm_health: bool
enable_dry_run_extract: bool
@@ -1642,6 +1672,7 @@ class HindsightConfig:
otel_service_name: str
otel_deployment_environment: str
metrics_include_bank_id: bool
metrics_backlog_enabled: bool
# Audit log configuration (static - server-level only)
audit_log_enabled: bool # Master switch for audit logging
@@ -1676,6 +1707,11 @@ class HindsightConfig:
embeddings_zeroentropy_encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT
embeddings_zeroentropy_batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE
embeddings_zeroentropy_latency: str | None = DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY
file_parser_markitdown_ocr_enabled: bool = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED
file_parser_markitdown_ocr_api_key: str | None = None
file_parser_markitdown_ocr_base_url: str | None = None
file_parser_markitdown_ocr_model: str | None = None
file_parser_markitdown_ocr_prompt: str = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
# Class-level sets for configuration categorization
@@ -1716,6 +1752,8 @@ class HindsightConfig:
"file_storage_gcs_service_account_key",
"file_storage_azure_account_key",
# File parser credentials
"file_parser_markitdown_ocr_api_key",
"file_parser_markitdown_ocr_base_url",
"file_parser_iris_token",
"file_parser_llama_parse_api_key",
}
@@ -1879,6 +1917,9 @@ class HindsightConfig:
f"Note: 'standard' is not a valid Bedrock service tier -- use unset for default tier."
)
# Validate gemini_service_tier
self.llm_gemini_service_tier = parse_gemini_service_tier(self.llm_gemini_service_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"
@@ -1996,6 +2037,11 @@ class HindsightConfig:
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_gemini_service_tier=(
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER) or DEFAULT_LLM_GEMINI_SERVICE_TIER)
if llm_provider.lower() == "gemini"
else 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"),
@@ -2345,6 +2391,7 @@ 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",
mcp_instructions=os.getenv(ENV_MCP_INSTRUCTIONS) or DEFAULT_MCP_INSTRUCTIONS,
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()
@@ -2424,6 +2471,18 @@ class HindsightConfig:
file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST))
if os.getenv(ENV_FILE_PARSER_ALLOWLIST)
else None,
file_parser_markitdown_ocr_enabled=os.getenv(
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED,
str(DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED),
).lower()
in ("1", "true", "yes", "on"),
file_parser_markitdown_ocr_api_key=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY) or None,
file_parser_markitdown_ocr_base_url=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL) or None,
file_parser_markitdown_ocr_model=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL) or None,
file_parser_markitdown_ocr_prompt=os.getenv(
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
),
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_parser_llama_parse_api_key=os.getenv(ENV_FILE_PARSER_LLAMA_PARSE_API_KEY) or None,
@@ -2614,6 +2673,8 @@ class HindsightConfig:
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
metrics_include_bank_id=os.getenv(ENV_METRICS_INCLUDE_BANK_ID, str(DEFAULT_METRICS_INCLUDE_BANK_ID)).lower()
in ("true", "1", "yes"),
metrics_backlog_enabled=os.getenv(ENV_METRICS_BACKLOG_ENABLED, str(DEFAULT_METRICS_BACKLOG_ENABLED)).lower()
in ("true", "1", "yes"),
# Audit log configuration (static, server-level only)
audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true",
audit_log_actions=[
@@ -8,6 +8,7 @@ Config values are resolved on every request to ensure consistency across
multiple API servers.
"""
import asyncio
import json
import logging
from dataclasses import asdict, replace
@@ -161,26 +162,83 @@ class ConfigResolver:
resolved_config = await self.resolve_full_config(bank_id, context)
config_dict = asdict(resolved_config)
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
# SECURITY: drop static/infrastructure + credential fields, then permission-filter.
filtered = self._strip_static_and_credential_fields(config_dict)
return await self._apply_permission_filter(filtered, bank_id, context)
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
def _strip_static_and_credential_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
"""Keep only configurable, non-credential fields.
# PERMISSIONS: Further filter based on tenant/bank permissions
SECURITY: excludes static/infrastructure fields and ALL credential fields
(API keys, base URLs, etc.) so a resolved config is safe to return over the API.
"""
return {
k: v for k, v in config_dict.items() if k in self._configurable_fields and k not in self._credential_fields
}
async def _apply_permission_filter(
self, filtered: dict[str, Any], bank_id: str, context: RequestContext | None
) -> dict[str, Any]:
"""Further restrict already-stripped config to the tenant/bank permission allow-list.
On extension error, leaves ``filtered`` unchanged (parity with the historical
single-bank path: a permissions lookup failure must not leak or drop fields).
"""
if not (self.tenant_extension and context):
return filtered
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
return filtered
async def get_bank_configs(
self, bank_ids: list[str], context: RequestContext | None = None
) -> dict[str, dict[str, Any]]:
"""Batch variant of :meth:`get_bank_config` for many banks.
Equivalent to calling ``get_bank_config`` per bank, but resolves the
global + tenant base once and loads every bank's ``banks.config`` JSONB
in a single query, instead of one config round-trip per bank. Used by
``list_banks`` to overlay disposition + mission without an N+1.
Returns a mapping of bank_id -> filtered configurable-field dict. A bank
with no config row still appears, mapped to the global+tenant base.
"""
if not bank_ids:
return {}
# Global + tenant base, resolved once (tenant override is per-request, not per-bank).
base_dict = asdict(self._global_config)
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
normalized_tenant = normalize_config_dict(tenant_overrides)
base_dict.update({k: v for k, v in normalized_tenant.items() if k in self._configurable_fields})
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
logger.warning(f"Failed to load tenant config for bulk resolve: {e}")
return filtered
# All bank overrides in one query, then merge + strip per bank.
bank_overrides = await self._load_bank_configs(bank_ids)
stripped = {
bank_id: self._strip_static_and_credential_fields({**base_dict, **bank_overrides.get(bank_id, {})})
for bank_id in bank_ids
}
# Permission filter is per-bank; resolve concurrently when an extension is present.
if not (self.tenant_extension and context):
return stripped
permission_filtered = await asyncio.gather(
*(self._apply_permission_filter(stripped[bank_id], bank_id, context) for bank_id in bank_ids)
)
return dict(zip(bank_ids, permission_filtered, strict=True))
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
@@ -219,6 +277,45 @@ class ConfigResolver:
return {}
async def _load_bank_configs(self, bank_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Bulk variant of :meth:`_load_bank_config`: load many banks' overrides in one query.
Returns a mapping of bank_id -> normalized active overrides. Banks with no row
(or an empty/all-tombstone config) are simply absent from the mapping.
"""
result: dict[str, dict[str, Any]] = {}
if not bank_ids:
return result
try:
async with self._backend.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT bank_id, config FROM {fq_table("banks")} WHERE bank_id = ANY($1)
""",
bank_ids,
)
for row in rows:
config_data = row["config"]
if not config_data:
continue
# Handle case where JSONB is returned as JSON string
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Normalize keys (handle both env var format and Python field format)
normalized = normalize_config_dict(config_data)
# Only active overrides for configurable fields. JSON null is a tombstone
# for "Server Default" in the bank-config UI and must not override defaults.
overrides = {
k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None
}
if overrides:
result[row["bank_id"]] = overrides
except Exception as e:
logger.error(f"Failed to bulk-load bank configs: {e}")
return result
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
@@ -449,6 +449,13 @@ class _CreateAction(BaseModel):
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
@field_validator("source_fact_ids", mode="before")
@classmethod
def ensure_list(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [v]
return v
class _UpdateAction(BaseModel):
text: str
@@ -461,6 +468,13 @@ class _UpdateAction(BaseModel):
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
@field_validator("source_fact_ids", mode="before")
@classmethod
def ensure_list(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [v]
return v
class _DeleteAction(BaseModel):
observation_id: str # UUID of the observation to remove
@@ -640,6 +654,7 @@ class ConsolidationPerfLog:
self.start_time = time.time()
self.lines: list[str] = []
self.timings: dict[str, float] = {}
self.timing_counts: dict[str, int] = {}
self.llm_calls: int = 0
self.total_obs_in_context: int = 0
self.total_prompt_chars: int = 0
@@ -649,11 +664,13 @@ class ConsolidationPerfLog:
self.lines.append(message)
def record_timing(self, key: str, duration: float) -> None:
"""Record a timing measurement."""
if key in self.timings:
self.timings[key] += duration
else:
self.timings[key] = duration
"""Record a timing measurement.
Tracks both total seconds and call count so the summary can
distinguish one slow call from many fast calls in aggregate.
"""
self.timings[key] = self.timings.get(key, 0.0) + duration
self.timing_counts[key] = self.timing_counts.get(key, 0) + 1
def record_llm_call(self, obs_count: int, prompt_chars: int) -> None:
"""Record stats for a single LLM call."""
@@ -676,6 +693,8 @@ class ConsolidationPerfLog:
"""
for key, value in other.timings.items():
self.timings[key] = self.timings.get(key, 0.0) + value
for key, count in other.timing_counts.items():
self.timing_counts[key] = self.timing_counts.get(key, 0) + count
self.llm_calls += other.llm_calls
self.total_obs_in_context += other.total_obs_in_context
self.total_prompt_chars += other.total_prompt_chars
@@ -1276,16 +1295,22 @@ async def _run_consolidation_job(
f"{stats['skipped']} skipped)"
)
# Add timing breakdown
# Add timing breakdown. Each phase is recorded once per call, so the count
# disambiguates a single slow call from many fast calls — important for
# operators triaging "the recall phase took 15s" log lines, where the
# total is the sum of many serial sub-calls rather than one slow query.
def _fmt(key: str) -> str:
total = perf.timings[key]
count = perf.timing_counts.get(key, 0)
if count > 1:
avg_ms = total * 1000.0 / count
return f"{key}={total:.3f}s ({count} calls, avg={avg_ms:.0f}ms)"
return f"{key}={total:.3f}s"
timing_parts = []
if "recall" in perf.timings:
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
if "llm" in perf.timings:
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
if "embedding" in perf.timings:
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
if "db_write" in perf.timings:
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
for key in ("recall", "llm", "embedding", "db_write"):
if key in perf.timings:
timing_parts.append(_fmt(key))
if perf.llm_calls > 0:
timing_parts.append(f"avg_obs={perf.total_obs_in_context / perf.llm_calls:.1f}")
@@ -190,7 +190,7 @@ class LocalSTEmbeddings(Embeddings):
device = "cpu"
logger.info("Embeddings: forcing CPU mode")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
@@ -198,10 +198,13 @@ class LocalSTEmbeddings(Embeddings):
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
# Intel Arc XPU support — torch.xpu is available when the XPU build is loaded
if not has_gpu and hasattr(torch, "xpu"):
has_gpu = torch.xpu.is_available()
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
device = None # Let sentence-transformers auto-detect GPU/MPS/XPU
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}")
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
@@ -709,7 +712,8 @@ class OpenAIEmbeddings(Embeddings):
class CodexOAuthEmbeddings(OpenAIEmbeddings):
"""
OpenAI embeddings using the Codex/ChatGPT OAuth token from ``~/.codex/auth.json``.
OpenAI embeddings using the Codex/ChatGPT OAuth token from the Codex
``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when unset).
Codex OAuth is an LLM-provider auth path in Hindsight, but the same bearer token
can also authenticate against the standard OpenAI embeddings endpoint. This keeps
@@ -6,6 +6,7 @@ enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, et
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any
from .response_models import LLMToolCallResult
@@ -252,3 +253,11 @@ class OutputTooLongError(Exception):
"""
pass
class ProviderRateLimitResetError(Exception):
"""Raised when an upstream provider says quota will reopen at a known time."""
def __init__(self, retry_at: datetime, message: str = "") -> None:
self.retry_at = retry_at
super().__init__(message)
@@ -10,7 +10,6 @@ import re
import time
import uuid
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
@@ -253,6 +252,7 @@ def create_llm_provider(
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
litellmrouter_config: dict[str, Any] | None = None,
gemini_service_tier: str | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -266,6 +266,7 @@ def create_llm_provider(
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".
gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper).
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
@@ -296,6 +297,12 @@ def create_llm_provider(
)
provider_lower = provider.lower()
if provider_lower == "gemini":
from ..config import parse_gemini_service_tier
gemini_service_tier = parse_gemini_service_tier(gemini_service_tier)
else:
gemini_service_tier = None
if provider_lower == "openai-codex":
return CodexLLM(
@@ -344,6 +351,7 @@ def create_llm_provider(
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
gemini_service_tier=gemini_service_tier,
prompt_cache_enabled=prompt_cache_enabled,
extra_body=extra_body,
)
@@ -496,6 +504,7 @@ class LLMProvider:
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
litellmrouter_config: dict[str, Any] | None = None,
gemini_service_tier: str | None = None,
):
"""
Initialize LLM provider.
@@ -509,6 +518,7 @@ class LLMProvider:
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_service_tier: Gemini service tier (None or "flex") - 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).
@@ -532,6 +542,7 @@ class LLMProvider:
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
self.gemini_service_tier = gemini_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
@@ -660,6 +671,22 @@ class LLMProvider:
except Exception:
pass # Config may not be initialized in test environments
if self.provider == "gemini":
from ..config import parse_gemini_service_tier
self.gemini_service_tier = parse_gemini_service_tier(self.gemini_service_tier)
if self.provider == "gemini" and self.gemini_service_tier is None:
from ..config import _get_raw_config
try:
raw_config = _get_raw_config()
self.gemini_service_tier = raw_config.llm_gemini_service_tier
except Exception:
pass # Config may not be initialized in test environments
elif self.provider != "gemini":
self.gemini_service_tier = None
# Prompt-prefix caching is a provider-agnostic toggle (default on): resolve
# it from the static server config for every provider when the caller didn't
# pass an explicit override. Providers that don't support caching ignore the
@@ -698,6 +725,7 @@ class LLMProvider:
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
bedrock_service_tier=self.bedrock_service_tier,
gemini_service_tier=self.gemini_service_tier,
extra_body=self.extra_body,
default_headers=self.default_headers,
vertexai_project_id=vertexai_project_id,
@@ -1023,7 +1051,9 @@ class LLMProvider:
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from ~/.codex/auth.json.
Load OAuth credentials from the Codex ``auth.json``.
Honors ``CODEX_HOME`` (falling back to ``~/.codex``).
Returns:
Tuple of (access_token, account_id).
@@ -1032,7 +1062,9 @@ class LLMProvider:
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
auth_file = Path.home() / ".codex" / "auth.json"
from .providers.codex_auth import default_codex_auth_file
auth_file = default_codex_auth_file()
if not auth_file.exists():
raise FileNotFoundError(
@@ -1142,10 +1174,12 @@ class LLMProvider:
ENV_LLM_BEDROCK_SERVICE_TIER,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_GEMINI_SERVICE_TIER,
ENV_LLM_MODEL,
ENV_LLM_PROVIDER,
ENV_LLM_REASONING_EFFORT,
_get_default_model_for_provider,
parse_gemini_service_tier,
)
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
@@ -1172,6 +1206,11 @@ class LLMProvider:
extra_body=extra_body,
default_headers=default_headers,
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
gemini_service_tier=(
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER))
if provider.lower() == "gemini"
else None
),
)
@@ -45,6 +45,7 @@ from .audit import AuditLogger, audit_context
from .bank_stats_cache import BankStatsCache
from .db import DatabaseBackend, create_database_backend
from .db_budget import budgeted_operation
from .llm_interface import ProviderRateLimitResetError
from .llm_trace import (
LLMRequestEntry,
LLMRequestListResponse,
@@ -744,6 +745,37 @@ def _resolve_refresh_tag_filtering(
return RefreshTagFiltering(tags=model_tags, tags_match=tags_match, tag_groups=None)
@dataclass
class ResolvedDispositionMission:
"""Disposition + mission after overlaying resolved bank config on the legacy columns."""
disposition: dict[str, int]
mission: str
def _overlay_bank_config_disposition_mission(
disposition: dict[str, int], mission: str, config_dict: dict[str, Any]
) -> ResolvedDispositionMission:
"""Overlay resolved bank config on top of the legacy banks.disposition /
banks.mission column values.
``reflect_mission`` and ``disposition_*`` in the resolved bank config take
precedence over the legacy DB columns. Shared by ``get_bank_profile`` and
``list_banks`` so the single-bank and list paths return identical
disposition + mission for the same bank.
"""
resolved_mission = config_dict.get("reflect_mission") or mission
cfg_skep = config_dict.get("disposition_skepticism")
cfg_lit = config_dict.get("disposition_literalism")
cfg_emp = config_dict.get("disposition_empathy")
resolved_disposition = {
"skepticism": cfg_skep if cfg_skep is not None else disposition["skepticism"],
"literalism": cfg_lit if cfg_lit is not None else disposition["literalism"],
"empathy": cfg_emp if cfg_emp is not None else disposition["empathy"],
}
return ResolvedDispositionMission(disposition=resolved_disposition, mission=resolved_mission)
class MemoryEngine(MemoryEngineInterface):
"""
Advanced memory system using temporal and semantic linking with PostgreSQL.
@@ -933,6 +965,7 @@ class MemoryEngine(MemoryEngineInterface):
default_headers=config.llm_default_headers,
litellmrouter_config=config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
)
# Store client and model for convenience (deprecated: use _llm_config.call() instead)
@@ -966,6 +999,7 @@ class MemoryEngine(MemoryEngineInterface):
default_headers=config.llm_default_headers,
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
)
# Reflect LLM config - for think/observe operations (can use lighter models)
@@ -994,6 +1028,7 @@ class MemoryEngine(MemoryEngineInterface):
default_headers=config.llm_default_headers,
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
)
# Consolidation LLM config - for mental model consolidation (can use efficient models)
@@ -1022,6 +1057,7 @@ class MemoryEngine(MemoryEngineInterface):
default_headers=config.llm_default_headers,
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
bedrock_service_tier=config.llm_bedrock_service_tier,
gemini_service_tier=config.llm_gemini_service_tier,
)
# Initialize cross-encoder reranker (cached for performance)
@@ -1752,6 +1788,9 @@ class MemoryEngine(MemoryEngineInterface):
audit_entry.response = {"status": "completed", "operation_id": operation_id}
except ProviderRateLimitResetError as e:
logger.warning(f"Task deferred until provider quota resets at {e.retry_at}: {e}")
raise DeferOperation(exec_date=e.retry_at, reason=str(e)) from e
except RetryTaskAt:
# Task-owned retry: let the poller handle scheduling
raise
@@ -2751,7 +2790,15 @@ class MemoryEngine(MemoryEngineInterface):
self._parser_registry = FileParserRegistry()
try:
self._parser_registry.register(MarkitdownParser())
self._parser_registry.register(
MarkitdownParser(
ocr_enabled=config.file_parser_markitdown_ocr_enabled,
ocr_api_key=config.file_parser_markitdown_ocr_api_key,
ocr_base_url=config.file_parser_markitdown_ocr_base_url,
ocr_model=config.file_parser_markitdown_ocr_model,
ocr_prompt=config.file_parser_markitdown_ocr_prompt,
)
)
logger.debug("Registered markitdown parser")
except ImportError:
logger.warning("markitdown not available - file parsing disabled")
@@ -3287,6 +3334,28 @@ class MemoryEngine(MemoryEngineInterface):
sub_doc_id = document_id or (sub_batch[0].get("document_id") if len(sub_batch) == 1 else None)
sub_offset = chunk_offsets.get(sub_doc_id, 0) if sub_doc_id else 0
# Count the chunks this sub-batch will produce BEFORE handing it
# to the orchestrator. retain_batch consumes (pops) each item's
# "content" while streaming, so reading it back after the call
# yields "" — and chunk_text("") returns [""] (count 1),
# advancing the per-document cursor by 1 regardless of the real
# chunk count. For slices that each span several chunks the next
# sub-batch then restarts ~1 slot in, colliding chunk_ids and
# overwriting earlier chunks (only ~1 new chunk survives per
# sub-batch). Capture it here while content is still present.
sub_chunk_count = 0
if sub_doc_id:
sub_chunk_count = sum(
len(
fact_extraction.chunk_text(
item.get("content", "") or "",
chunking_config.chunk_size,
structured_chunk_size=chunking_config.structured_chunk_size,
)
)
for item in sub_batch
)
sub_results, sub_usage, sub_processed = await self._retain_batch_async_internal(
bank_id=bank_id,
contents=sub_batch,
@@ -3306,20 +3375,10 @@ class MemoryEngine(MemoryEngineInterface):
)
# Advance the document's chunk_index cursor by the number of
# chunks this sub-batch produced (computed with the same chunk
# size the orchestrator uses), so the next sub-batch sharing the
# document continues the sequence.
# chunks this sub-batch produced (counted above, before the
# orchestrator consumed the content), so the next sub-batch
# sharing the document continues the sequence.
if sub_doc_id:
sub_chunk_count = sum(
len(
fact_extraction.chunk_text(
item.get("content", "") or "",
chunking_config.chunk_size,
structured_chunk_size=chunking_config.structured_chunk_size,
)
)
for item in sub_batch
)
# retain_batch only prepends the existing body on the global
# first sub-batch (is_first_batch == i == 1), so fold its chunk
# count in only there.
@@ -8112,25 +8171,15 @@ class MemoryEngine(MemoryEngineInterface):
# reflect_mission and disposition in config take precedence over the legacy DB columns
config_dict = await self._config_resolver.get_bank_config(bank_id, request_context)
mission = config_dict.get("reflect_mission") or profile["mission"]
# Overlay disposition from config if explicitly set; fall back to DB values
db_disp = profile["disposition"]
db_disp_dict = db_disp.model_dump() if hasattr(db_disp, "model_dump") else dict(db_disp)
cfg_skep = config_dict.get("disposition_skepticism")
cfg_lit = config_dict.get("disposition_literalism")
cfg_emp = config_dict.get("disposition_empathy")
disposition = {
"skepticism": cfg_skep if cfg_skep is not None else db_disp_dict["skepticism"],
"literalism": cfg_lit if cfg_lit is not None else db_disp_dict["literalism"],
"empathy": cfg_emp if cfg_emp is not None else db_disp_dict["empathy"],
}
resolved = _overlay_bank_config_disposition_mission(db_disp_dict, profile["mission"], config_dict)
return {
"bank_id": bank_id,
"name": profile["name"],
"disposition": disposition,
"mission": mission,
"disposition": resolved.disposition,
"mission": resolved.mission,
}
async def _ensure_bank_exists(
@@ -8345,6 +8394,17 @@ class MemoryEngine(MemoryEngineInterface):
BankListContext(banks=banks, request_context=request_context)
)
banks = result.banks
# Overlay resolved bank config (reflect_mission + disposition_*) on top of the
# legacy banks.disposition / banks.mission columns, mirroring get_bank_profile so
# the list and get paths return identical disposition + mission for a bank.
# Resolve every bank's config in one batch (single config-column query + a single
# tenant-config resolve) rather than one round-trip per bank.
configs = await self._config_resolver.get_bank_configs([bank["bank_id"] for bank in banks], request_context)
for bank in banks:
resolved = _overlay_bank_config_disposition_mission(
bank["disposition"], bank["mission"], configs.get(bank["bank_id"], {})
)
bank["disposition"], bank["mission"] = resolved.disposition, resolved.mission
return banks
# ==================== Reflect Methods ====================
@@ -3,43 +3,116 @@
import asyncio
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
from .base import FileParser
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class MarkitdownOcrOptions:
"""OpenAI-compatible OCR options passed through to MarkItDown."""
# Keep this typed as object so the OpenAI SDK import stays lazy for non-OCR users.
llm_client: object
llm_model: str
llm_prompt: str
class MarkitdownParser(FileParser):
"""
Markitdown file parser.
Uses Microsoft's markitdown library to convert various file formats
to markdown including PDF, Office docs, images (via OCR), audio, HTML.
to markdown including PDF, Office docs, images with optional OCR,
audio, HTML.
Supported formats:
- PDF (.pdf)
- Word (.docx, .doc)
- PowerPoint (.pptx, .ppt)
- Excel (.xlsx, .xls)
- Images (.jpg, .jpeg, .png) - with OCR
- Images (.jpg, .jpeg, .png) - optional OCR
- HTML (.html, .htm)
- Text (.txt, .md)
- Audio (.mp3, .wav) - with transcription
"""
def __init__(self):
def __init__(
self,
*,
ocr_enabled: bool = False,
ocr_api_key: str | None = None,
ocr_base_url: str | None = None,
ocr_model: str | None = None,
ocr_prompt: str | None = None,
):
"""Initialize markitdown parser."""
# Lazy import to avoid requiring markitdown for all users
try:
from markitdown import MarkItDown
self._markitdown = MarkItDown()
except ImportError as e:
raise ImportError(
"markitdown package is required for file parsing. Install with: pip install markitdown"
) from e
self._ocr_enabled = ocr_enabled
if ocr_enabled:
ocr_options = self._build_ocr_options(
api_key=ocr_api_key,
base_url=ocr_base_url,
model=ocr_model,
prompt=ocr_prompt,
)
self._markitdown = MarkItDown(
llm_client=ocr_options.llm_client,
llm_model=ocr_options.llm_model,
llm_prompt=ocr_options.llm_prompt,
)
else:
self._markitdown = MarkItDown()
def _build_ocr_options(
self,
*,
api_key: str | None,
base_url: str | None,
model: str | None,
prompt: str | None,
) -> MarkitdownOcrOptions:
"""Build MarkItDown options for OpenAI-compatible image OCR."""
if not model or not model.strip():
raise ValueError(
"Markitdown OCR is enabled but no model is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL to an OpenAI-compatible OCR/vision model "
"with image-input support."
)
if not api_key:
raise ValueError(
"Markitdown OCR is enabled but no API key is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY."
)
if not base_url or not base_url.strip():
raise ValueError(
"Markitdown OCR is enabled but no base URL is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL to an OpenAI-compatible OCR/vision endpoint."
)
try:
from openai import OpenAI
except ImportError as e:
raise RuntimeError("openai package is required when Markitdown OCR is enabled.") from e
return MarkitdownOcrOptions(
llm_client=OpenAI(api_key=api_key, base_url=base_url.strip()),
llm_model=model.strip(),
llm_prompt=prompt or DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
)
async def convert(self, file_data: bytes, filename: str) -> str:
"""Parse file to markdown using markitdown."""
# markitdown is synchronous, so we run it in executor to avoid blocking
@@ -48,6 +121,13 @@ class MarkitdownParser(FileParser):
def _convert_sync(self, file_data: bytes, filename: str) -> str:
"""Synchronous parsing (runs in thread pool)."""
if self._is_image_file(filename) and not self._ocr_enabled:
raise RuntimeError(
"Image OCR is not enabled for the markitdown parser. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=true and configure an OpenAI-compatible "
"OCR/vision endpoint with image-input support, or choose an OCR-capable parser."
)
# Write to temp file (markitdown requires file path)
with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp:
tmp.write(file_data)
@@ -73,6 +153,11 @@ class MarkitdownParser(FileParser):
except Exception:
pass
@staticmethod
def _is_image_file(filename: str) -> bool:
"""Return whether the file type needs OCR to extract useful text."""
return Path(filename).suffix.lower() in {".jpg", ".jpeg", ".png"}
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""Check if markitdown supports this file type."""
# Supported extensions (from markitdown docs)
@@ -85,7 +170,7 @@ class MarkitdownParser(FileParser):
".ppt",
".xlsx",
".xls",
# Images (with OCR)
# Images (optional OCR)
".jpg",
".jpeg",
".png",
@@ -60,6 +60,22 @@ _CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
)
def default_codex_auth_file() -> Path:
"""Return the path to Codex's ``auth.json``.
Honors the ``CODEX_HOME`` environment variable — the same variable the
canonical ``@openai/codex`` CLI uses to relocate its config/credentials
directory — and falls back to ``~/.codex`` when it is unset or empty.
Resolved lazily on each call (rather than cached at import time) so that
the environment is read at the point of use.
"""
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
return Path(codex_home) / "auth.json"
return Path.home() / ".codex" / "auth.json"
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
@@ -86,7 +102,7 @@ class CodexAuthManager:
The OAuth refresh token. May be ``None`` when the auth file omits it;
the provider still works as a one-shot loader in that case.
auth_file:
Path to ``~/.codex/auth.json``. Used for re-reading the refresh token
Path to the Codex ``auth.json``. Used for re-reading the refresh token
on demand and for atomic persistence of rotated credentials.
"""
@@ -115,7 +131,8 @@ class CodexAuthManager:
Parameters
----------
auth_file:
Defaults to ``~/.codex/auth.json``.
Defaults to ``$CODEX_HOME/auth.json`` (or ``~/.codex/auth.json``
when ``CODEX_HOME`` is unset).
Raises
------
@@ -126,7 +143,7 @@ class CodexAuthManager:
``auth_mode``.
"""
if auth_file is None:
auth_file = Path.home() / ".codex" / "auth.json"
auth_file = default_codex_auth_file()
if not auth_file.exists():
raise FileNotFoundError(f"Codex auth file not found: {auth_file}. Run 'codex auth login' to authenticate.")
@@ -2,8 +2,9 @@
OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
without separate OpenAI Platform API credits. It uses OAuth tokens from
~/.codex/auth.json and communicates with the ChatGPT backend API.
without separate OpenAI Platform API credits. It uses OAuth tokens from the
Codex ``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when
``CODEX_HOME`` is unset) and communicates with the ChatGPT backend API.
Tokens are refreshed automatically: the provider decodes the access_token
JWT's ``exp`` claim and proactively refreshes via
@@ -35,6 +36,7 @@ from .codex_auth import (
_CODEX_TOKEN_REFRESH_SKEW_SECONDS,
CodexAuthManager,
CodexRefreshExpiredError,
default_codex_auth_file,
)
# Re-export for backward compatibility (tests import from this module).
@@ -55,14 +57,15 @@ class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
Authenticates using ChatGPT Plus/Pro credentials stored in ~/.codex/auth.json
and makes API calls to chatgpt.com/backend-api/codex/responses.
Authenticates using ChatGPT Plus/Pro credentials stored in the Codex
``auth.json`` (honoring ``CODEX_HOME``, default ``~/.codex``) and makes API
calls to chatgpt.com/backend-api/codex/responses.
"""
def __init__(
self,
provider: str,
api_key: str, # Will be ignored, reads from ~/.codex/auth.json
api_key: str, # Will be ignored, reads from the Codex auth.json (CODEX_HOME or ~/.codex)
base_url: str,
model: str,
reasoning_effort: str = "low",
@@ -81,12 +84,14 @@ class CodexLLM(LLMInterface):
refresh_token = self._load_codex_refresh_token()
logger.info(f"Loaded Codex OAuth credentials for account: {account_id}")
except Exception as e:
auth_file = default_codex_auth_file()
raise RuntimeError(
f"Failed to load Codex OAuth credentials from ~/.codex/auth.json: {e}\n\n"
f"Failed to load Codex OAuth credentials from {auth_file}: {e}\n\n"
"To set up Codex authentication:\n"
"1. Install Codex CLI: npm install -g @openai/codex\n"
"2. Login: codex auth login\n"
"3. Verify: ls ~/.codex/auth.json\n\n"
f"3. Verify: ls {auth_file}\n\n"
"(Set CODEX_HOME to use a credentials directory other than ~/.codex.)\n\n"
"Or use a different provider (openai, anthropic, gemini) with API keys."
) from e
@@ -94,7 +99,7 @@ class CodexLLM(LLMInterface):
access_token=access_token,
account_id=account_id,
refresh_token=refresh_token,
auth_file=Path.home() / ".codex" / "auth.json",
auth_file=default_codex_auth_file(),
)
# Use ChatGPT backend API endpoint. Codex auth is tied to
@@ -156,7 +161,7 @@ class CodexLLM(LLMInterface):
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from ~/.codex/auth.json.
Load OAuth credentials from the Codex ``auth.json`` (CODEX_HOME or ~/.codex).
Returns:
Tuple of (access_token, account_id).
@@ -165,7 +170,7 @@ class CodexLLM(LLMInterface):
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
auth_file = Path.home() / ".codex" / "auth.json"
auth_file = default_codex_auth_file()
if not auth_file.exists():
raise FileNotFoundError(
@@ -197,9 +202,7 @@ class CodexLLM(LLMInterface):
pre- and post-``__init__`` because it does not depend on
``_auth_manager`` being constructed yet.
"""
auth_file = (
self._auth_manager._auth_file if hasattr(self, "_auth_manager") else Path.home() / ".codex" / "auth.json"
)
auth_file = self._auth_manager._auth_file if hasattr(self, "_auth_manager") else default_codex_auth_file()
return CodexAuthManager.load_refresh_token_from_file(auth_file)
@staticmethod
@@ -76,6 +76,7 @@ class GeminiLLM(LLMInterface):
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
self._service_tier: str | None = kwargs.get("gemini_service_tier")
# User-configured extra params merged into the GenerateContentConfig of
# every call. Gemini's request body nests generation params, so we expose
@@ -106,6 +107,16 @@ class GeminiLLM(LLMInterface):
self._client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini API: model={self.model}")
def _apply_service_tier(self, config_kwargs: dict[str, Any]) -> None:
if not self._service_tier:
return
http_options = dict(config_kwargs.get("http_options") or {})
extra_body = dict(http_options.get("extra_body") or {})
extra_body.setdefault("service_tier", self._service_tier)
http_options["extra_body"] = extra_body
config_kwargs["http_options"] = http_options
def _init_vertexai(self, **kwargs: Any) -> None:
"""Initialize Vertex AI client with project, region, and credentials."""
# Extract Vertex AI config from kwargs
@@ -273,6 +284,7 @@ class GeminiLLM(LLMInterface):
def _build_generation_config(use_cache: bool) -> "genai_types.GenerateContentConfig | None":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
self._apply_service_tier(config_kwargs)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
elif system_instruction:
@@ -604,6 +616,7 @@ class GeminiLLM(LLMInterface):
def _build_tools_config(use_cache: bool) -> "genai_types.GenerateContentConfig":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
self._apply_service_tier(config_kwargs)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
else:
@@ -15,9 +15,13 @@ is handled automatically by LiteLLM.
import asyncio
import json
import logging
import os
import time
from typing import Any
from litellm.exceptions import Timeout as LiteLLMTimeout
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -47,13 +51,15 @@ class LiteLLMLLM(LLMInterface):
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
timeout: float | None = None,
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)
self.timeout = timeout
# ``None`` falls back to HINDSIGHT_API_LLM_TIMEOUT, then DEFAULT_LLM_TIMEOUT — never None,
# so the hard ``asyncio.wait_for`` backstop in ``call`` is always bounded.
self.timeout = timeout if timeout is not None else float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
self._litellm: Any = None
# User-configured extra params merged as top-level kwargs into every
# completion call so LiteLLM normalizes them per-provider (e.g. maps
@@ -209,7 +215,10 @@ class LiteLLMLLM(LLMInterface):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._acompletion(**call_kwargs)
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
@@ -304,6 +313,25 @@ class LiteLLMLLM(LLMInterface):
logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts")
raise
except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e:
# litellm/httpx don't always honor their own ``timeout=`` (e.g. a connection held
# open with no token progress), so ``wait_for`` is the hard cap that cancels a hung
# call regardless — otherwise one straggler pins a worker slot and stalls its gather.
last_exception = e
exc_name = type(e).__name__
if attempt < max_retries:
logger.warning(
f"LiteLLM call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..."
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
logger.error(
f"LiteLLM call timed out after {self.timeout}s on {attempt + 1} attempts "
f"({exc_name}, scope={scope})"
)
raise
except Exception as e:
error_str = str(e).lower()
# Fast fail on auth errors
@@ -354,7 +382,10 @@ class LiteLLMLLM(LLMInterface):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._acompletion(**call_kwargs)
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
message = response.choices[0].message
content = message.content
@@ -424,6 +455,23 @@ class LiteLLMLLM(LLMInterface):
output_tokens=output_tokens,
)
except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e:
# See ``call`` — hard cap so a hung completion cannot block
# forever and pin a worker slot / concurrency permit.
last_exception = e
exc_name = type(e).__name__
if attempt < max_retries:
logger.warning(
f"LiteLLM tool call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..."
)
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
logger.error(
f"LiteLLM tool call timed out after {self.timeout}s on {attempt + 1} attempts "
f"({exc_name}, scope={scope})"
)
raise
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
@@ -67,7 +67,7 @@ class LiteLLMRouterLLM(LiteLLMLLM):
model: str,
config: dict[str, Any],
reasoning_effort: str = "low",
timeout: float = 300.0,
timeout: float | None = None,
**kwargs: Any,
):
super().__init__(
@@ -26,6 +26,8 @@ import logging
import os
import re
import time
from datetime import UTC, datetime, timedelta
from email.utils import parsedate_to_datetime
from typing import Any
from urllib.parse import parse_qs, urlparse, urlunparse
@@ -34,7 +36,7 @@ from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinish
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.bank_attribution import apply_bank_attribution
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError, ProviderRateLimitResetError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
@@ -83,6 +85,49 @@ def _strip_code_fences(content: str) -> str:
return content
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
# (e.g. MiniMax-M3) leak the chain-of-thought wrapped in these tags into the
# response body instead of a separate reasoning_content field. Each entry is
# (open_tag, close_tag); the open tag also matches when the close tag is missing
# (truncated output) so a dangling block is removed to end-of-string.
_REASONING_TAG_PAIRS: tuple[tuple[str, str], ...] = (
("<think>", "</think>"),
("<thinking>", "</thinking>"),
("<thought>", "</thought>"),
("<reasoning>", "</reasoning>"),
("|startthink|", "|endthink|"),
)
def _strip_reasoning_tags(text: str) -> str:
"""Strip extended-thinking/reasoning blocks from an LLM response.
Removes the full set of tag styles emitted by reasoning models:
``<think>``, ``<thinking>``, ``<thought>``, ``<reasoning>`` and the
``|startthink|...|endthink|`` markers. Both the structured (JSON) path and
the free-form path must call this — otherwise a non-structured response
(e.g. a mental-model markdown blob from MiniMax-M3) leaks the raw
``<think>...</think>`` verbatim into stored memories.
Handles two cases:
1. Closed blocks: ``<think>...</think>`` removed wherever they appear.
2. Unclosed blocks: a dangling ``<think>`` with no closing tag (model output
truncated mid-thought) is removed from the open tag to end-of-string.
Returns the input unchanged (modulo surrounding whitespace) when no tags are
present.
"""
if not text:
return text
for open_tag, close_tag in _REASONING_TAG_PAIRS:
open_re = re.escape(open_tag)
close_re = re.escape(close_tag)
# Closed blocks first, then any remaining unclosed (truncated) block.
text = re.sub(rf"{open_re}.*?{close_re}", "", text, flags=re.DOTALL)
text = re.sub(rf"{open_re}.*", "", text, flags=re.DOTALL)
return text.strip()
def _response_get(response: Any, key: str, default: Any = None) -> Any:
if isinstance(response, dict):
return response.get(key, default)
@@ -234,6 +279,122 @@ def _summarize_status_error(e: APIStatusError, body_max: int = 400) -> str:
return f"HTTP {e.status_code}: {body_str or '<no body>'}"
_RATE_LIMIT_RESET_AT_RE = re.compile(
r"\breset at\s+"
r"(?P<reset_at>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\s*(?:Z|[+-]\d{2}:?\d{2}))?)",
re.IGNORECASE,
)
_RATE_LIMIT_WINDOW_RE = re.compile(
r"\b(?:for|in)\s+(?P<amount>\d+)\s*(?P<unit>second|minute|hour|day)s?\b",
re.IGNORECASE,
)
def _status_error_body_text(e: APIStatusError) -> str:
body: Any = getattr(e, "body", None)
if body is None:
try:
body = e.response.text
except Exception:
body = None
if isinstance(body, (dict, list)):
try:
return json.dumps(body, default=str, ensure_ascii=False)
except Exception:
return str(body)
return str(body or "").strip()
def _parse_retry_after_header(value: str | None, now: datetime) -> datetime | None:
if not value:
return None
raw = value.strip()
try:
seconds = float(raw)
except ValueError:
seconds = -1.0
if seconds >= 0:
return now + timedelta(seconds=seconds)
try:
parsed = parsedate_to_datetime(raw)
except (TypeError, ValueError, IndexError, OverflowError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _parse_reset_at_datetime(value: str) -> datetime | None:
raw = value.strip().replace(" ", "T")
if raw.endswith("Z"):
raw = f"{raw[:-1]}+00:00"
elif re.search(r"[+-]\d{4}$", raw):
raw = f"{raw[:-2]}:{raw[-2:]}"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
# Some providers (z.ai included) return a wall-clock reset timestamp
# without a zone. Interpret it in the host's local zone so logs, status
# pages, and the queued next_retry_at describe the same operator-facing
# clock instead of silently shifting by UTC offset.
parsed = parsed.astimezone()
return parsed.astimezone(UTC)
def _rate_limit_retry_at(e: APIStatusError) -> datetime | None:
now = datetime.now(UTC)
response = getattr(e, "response", None)
headers = getattr(response, "headers", None)
if headers is not None:
retry_at = _parse_retry_after_header(headers.get("retry-after") or headers.get("Retry-After"), now)
if retry_at is not None and retry_at > now:
return retry_at
body_text = _status_error_body_text(e)
reset_match = _RATE_LIMIT_RESET_AT_RE.search(body_text)
if reset_match:
retry_at = _parse_reset_at_datetime(reset_match.group("reset_at"))
if retry_at is not None and retry_at > now:
return retry_at
window_match = _RATE_LIMIT_WINDOW_RE.search(body_text)
if not window_match:
return None
amount = int(window_match.group("amount"))
unit = window_match.group("unit").lower()
if unit == "second":
seconds = amount
elif unit == "minute":
seconds = amount * 60
elif unit == "hour":
seconds = amount * 3600
else:
seconds = amount * 86400
return now + timedelta(seconds=seconds)
def _raise_provider_quota_defer(
e: APIStatusError, *, provider: str, model: str, scope: str, max_backoff: float
) -> None:
if e.status_code != 429:
return
retry_at = _rate_limit_retry_at(e)
if retry_at is None:
return
if (retry_at - datetime.now(UTC)).total_seconds() <= max_backoff:
return
summary = _summarize_status_error(e)
raise ProviderRateLimitResetError(
retry_at=retry_at,
message=(
f"Provider quota exhausted ({provider}/{model}, scope={scope}); retry at {retry_at.isoformat()}: {summary}"
),
) from e
class OpenAICompatibleLLM(LLMInterface):
"""
LLM provider for OpenAI-compatible APIs.
@@ -269,7 +430,7 @@ class OpenAICompatibleLLM(LLMInterface):
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high").
timeout: Request timeout in seconds (uses env var or 300s default).
timeout: Request timeout in seconds (uses env var or 120s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
extra_body: Extra body params merged into every API call.
**kwargs: Additional provider-specific parameters.
@@ -617,15 +778,10 @@ class OpenAICompatibleLLM(LLMInterface):
scope=scope,
)
# Strip reasoning model thinking tags
# Strip reasoning model thinking tags (closed and unclosed).
# Supports: <think>, <thinking>, <thought>, <reasoning>, |startthink|/|endthink|
original_len = len(content)
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
content = re.sub(r"<thought>.*?</thought>", "", content, flags=re.DOTALL)
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
content = content.strip()
content = _strip_reasoning_tags(content)
if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
@@ -674,6 +830,13 @@ class OpenAICompatibleLLM(LLMInterface):
scope=scope,
)
# Free-form (non-structured) output also leaks reasoning tags:
# reasoning models like MiniMax-M3 wrap their chain-of-thought
# in <think>...</think> in the response body. Without this strip
# a mental-model markdown blob is stored verbatim with the raw
# thinking tags. Mirrors the structured-output path above.
result = _strip_reasoning_tags(result)
# Record token usage metrics
duration = time.time() - start_time
usage = response.usage
@@ -761,6 +924,10 @@ class OpenAICompatibleLLM(LLMInterface):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
# Handle tool_use_failed error - model outputted in tool call format
if e.status_code == 400 and response_format is not None:
try:
@@ -814,7 +981,6 @@ class OpenAICompatibleLLM(LLMInterface):
f"scope={scope}): {_summarize_status_error(e)}"
)
raise
except ProviderResponseError as e:
last_exception = e
if e.retryable and attempt < max_retries:
@@ -1047,6 +1213,10 @@ class OpenAICompatibleLLM(LLMInterface):
f"not retrying: {_summarize_status_error(e)}"
)
raise
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
last_exception = e
if attempt < max_retries:
logger.warning(
@@ -1060,7 +1230,6 @@ class OpenAICompatibleLLM(LLMInterface):
f"({self.provider}/{self.model}, scope={scope}): {_summarize_status_error(e)}"
)
raise
except Exception:
raise
@@ -14,6 +14,7 @@ from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ..llm_interface import ProviderRateLimitResetError
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
@@ -1792,10 +1793,21 @@ async def extract_facts_from_text(
total_usage = total_usage + chunk_usage
if failed_chunks:
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
quota_errors = [err for _, err in failed_chunks if isinstance(err, ProviderRateLimitResetError)]
if quota_errors and len(quota_errors) == len(failed_chunks):
retry_at = max(err.retry_at for err in quota_errors)
raise ProviderRateLimitResetError(
retry_at=retry_at,
message=(
f"Fact extraction deferred by provider quota: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}. Provider detail: {quota_errors[0]}"
),
) from quota_errors[0]
# Fail the entire retain — partial extraction is not acceptable.
# All successfully extracted facts are discarded because the transaction
# hasn't committed yet. The worker poller will retry the entire task.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
raise RuntimeError(
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}"
@@ -1615,8 +1615,19 @@ async def _streaming_retain_batch(
# Check if facts are already committed (recovery from previous crash).
# If so, skip extraction+writes and jump straight to final ANN pass.
# ---------------------------------------------------------------------------
# Only the call that starts a document at chunk 0 may take the whole-document
# skip. When an oversized single item is split into several sequential
# sub-batches that SHARE one document_id AND one operation_id (see
# _split_contents_into_sub_batches), the first sub-batch commits its chunks
# and stamps effective_doc_id into result_metadata.facts_committed_document_ids.
# Without the offset gate, every later sub-batch (chunk_index_offset > 0) would
# then see its own document already "committed" and skip extraction, dropping
# all chunks past the first slice. A non-zero offset inherently means this call
# continues a document another sub-batch already started, so it must always do
# its work — crash-safety for those chunks still comes from the per-chunk hash
# recovery (existing_chunk_hashes) below.
facts_already_committed = False
if operation_id:
if operation_id and chunk_index_offset == 0:
try:
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
@@ -97,6 +97,10 @@ class PrecheckContext:
- ``bank_id``: parsed from the URL path.
- ``request_context``: the authenticated :class:`RequestContext` (tenant
already resolved by the tenant extension).
- ``content_length``: value of the ``Content-Length`` request header as an
int, or ``None`` when the header is absent or unparseable (e.g. chunked
transfer encoding). Lets a precheck make size-aware decisions — such as
an upper-bound cost estimate — without reading or deserialising the body.
Implementations should keep precheck cheap and side-effect-free. The
full per-request validators (``validate_retain`` / ``validate_recall``
@@ -107,6 +111,7 @@ class PrecheckContext:
operation: str
bank_id: str
request_context: "RequestContext"
content_length: int | None = None
@dataclass
+119 -61
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from typing import Any, Callable
from fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import TypeAdapter
from hindsight_api import MemoryEngine
@@ -199,6 +200,47 @@ def build_content_dict(
return content_dict, None
# MCP tool annotations. Hindsight is a closed memory store (no open-world / internet
# access), so openWorldHint=False throughout. readOnlyHint lets clients group and
# auto-approve safe reads; destructiveHint flags tools that delete or clear memory.
_READ_ONLY_TOOLS = {
"recall",
"reflect",
"list_banks",
"get_bank",
"get_bank_stats",
"list_mental_models",
"get_mental_model",
"list_directives",
"list_memories",
"get_memory",
"list_documents",
"get_document",
"list_operations",
"get_operation",
"list_tags",
}
_DESTRUCTIVE_TOOLS = {
"delete_bank",
"clear_memories",
"clear_mental_model",
"delete_mental_model",
"delete_directive",
"delete_document",
"invalidate_memory",
}
def _tool_annotations(name: str) -> ToolAnnotations:
if name in _READ_ONLY_TOOLS:
return ToolAnnotations(readOnlyHint=True, openWorldHint=False)
if name in _DESTRUCTIVE_TOOLS:
return ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=False)
# Everything else writes but does not destructively delete/clear memory
# (retain, create_*, update_*, refresh_mental_model, cancel_operation).
return ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False)
def register_mcp_tools(
mcp: FastMCP,
memory: MemoryEngine,
@@ -552,7 +594,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if config.include_bank_id_param:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("retain"))
async def retain(
content: str,
context: str = "general",
@@ -608,7 +650,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
else:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("retain"))
async def retain(
content: str,
context: str = "general",
@@ -666,7 +708,7 @@ def _register_sync_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("sync_retain"))
async def sync_retain(
content: str,
context: str = "general",
@@ -724,7 +766,7 @@ def _register_sync_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("sync_retain"))
async def sync_retain(
content: str,
context: str = "general",
@@ -785,7 +827,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if config.include_bank_id_param:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("recall"))
async def recall(
query: str,
max_tokens: int = 4096,
@@ -857,7 +899,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
else:
@mcp.tool(description=description)
@mcp.tool(description=description, annotations=_tool_annotations("recall"))
async def recall(
query: str,
max_tokens: int = 4096,
@@ -931,7 +973,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("reflect"))
async def reflect(
query: str,
context: str | None = None,
@@ -941,6 +983,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: list[str] | None = None,
tags_match: str = "any",
include_based_on: bool = False,
include_trace: bool = False,
bank_id: str | None = None,
) -> str:
"""
@@ -971,6 +1014,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: Optional tags to filter memories by (e.g., ['project:alpha'])
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
include_based_on: Include source facts used for synthesis. Defaults to false because broad reflections can exceed MCP client result limits.
include_trace: Include the reflection's internal tool_trace/llm_trace. Defaults to false because the trace can be tens of KB and overflow MCP client context; enable only for debugging.
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
"""
try:
@@ -1000,6 +1044,12 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
result_data = json.loads(reflect_result.model_dump_json(indent=2))
if not include_based_on:
result_data.pop("based_on", None)
if not include_trace:
# The agentic reflect loop's tool_trace/llm_trace can be tens of KB
# (full mental-model text) and silently overflow MCP client context;
# the REST API omits it by default too. Opt in via include_trace.
result_data.pop("tool_trace", None)
result_data.pop("llm_trace", None)
if response_schema is not None and hasattr(reflect_result, "structured_output"):
result_data["structured_output"] = reflect_result.structured_output
return json.dumps(result_data, indent=2)
@@ -1012,7 +1062,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("reflect"))
async def reflect(
query: str,
context: str | None = None,
@@ -1022,6 +1072,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: list[str] | None = None,
tags_match: str = "any",
include_based_on: bool = False,
include_trace: bool = False,
) -> dict:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
@@ -1051,6 +1102,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
tags: Optional tags to filter memories by (e.g., ['project:alpha'])
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
include_based_on: Include source facts used for synthesis. Defaults to false because broad reflections can exceed MCP client result limits.
include_trace: Include the reflection's internal tool_trace/llm_trace. Defaults to false because the trace can be tens of KB and overflow MCP client context; enable only for debugging.
"""
try:
target_bank = config.bank_id_resolver()
@@ -1079,6 +1131,12 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
result_data = reflect_result.model_dump()
if not include_based_on:
result_data.pop("based_on", None)
if not include_trace:
# The agentic reflect loop's tool_trace/llm_trace can be tens of KB
# (full mental-model text) and silently overflow MCP client context;
# the REST API omits it by default too. Opt in via include_trace.
result_data.pop("tool_trace", None)
result_data.pop("llm_trace", None)
if response_schema is not None and hasattr(reflect_result, "structured_output"):
result_data["structured_output"] = reflect_result.structured_output
return result_data
@@ -1093,7 +1151,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the list_banks tool."""
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_banks"))
async def list_banks() -> str:
"""
List all available memory banks.
@@ -1118,7 +1176,7 @@ def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_bank tool."""
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_bank"))
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
@@ -1182,7 +1240,7 @@ def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCP
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_mental_models"))
async def list_mental_models(
tags: list[str] | None = None,
detail: str = "full",
@@ -1221,7 +1279,7 @@ def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCP
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_mental_models"))
async def list_mental_models(
tags: list[str] | None = None,
detail: str = "full",
@@ -1262,7 +1320,7 @@ def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_mental_model"))
async def get_mental_model(
mental_model_id: str,
detail: str = "full",
@@ -1302,7 +1360,7 @@ def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_mental_model"))
async def get_mental_model(
mental_model_id: str,
detail: str = "full",
@@ -1344,7 +1402,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_mental_model"))
async def create_mental_model(
name: str,
source_query: str,
@@ -1428,7 +1486,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_mental_model"))
async def create_mental_model(
name: str,
source_query: str,
@@ -1510,7 +1568,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_mental_model"))
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
@@ -1571,7 +1629,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_mental_model"))
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
@@ -1634,7 +1692,7 @@ def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_mental_model"))
async def delete_mental_model(
mental_model_id: str,
bank_id: str | None = None,
@@ -1670,7 +1728,7 @@ def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_mental_model"))
async def delete_mental_model(
mental_model_id: str,
) -> dict:
@@ -1708,7 +1766,7 @@ def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: M
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("refresh_mental_model"))
async def refresh_mental_model(
mental_model_id: str,
bank_id: str | None = None,
@@ -1752,7 +1810,7 @@ def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: M
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("refresh_mental_model"))
async def refresh_mental_model(
mental_model_id: str,
) -> dict:
@@ -1796,7 +1854,7 @@ def _register_clear_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCP
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_mental_model"))
async def clear_mental_model(
mental_model_id: str,
bank_id: str | None = None,
@@ -1842,7 +1900,7 @@ def _register_clear_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCP
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_mental_model"))
async def clear_mental_model(
mental_model_id: str,
) -> dict:
@@ -1893,7 +1951,7 @@ def _register_list_directives(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_directives"))
async def list_directives(
tags: list[str] | None = None,
active_only: bool = True,
@@ -1931,7 +1989,7 @@ def _register_list_directives(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_directives"))
async def list_directives(
tags: list[str] | None = None,
active_only: bool = True,
@@ -1971,7 +2029,7 @@ def _register_create_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_directive"))
async def create_directive(
name: str,
content: str,
@@ -2017,7 +2075,7 @@ def _register_create_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("create_directive"))
async def create_directive(
name: str,
content: str,
@@ -2065,7 +2123,7 @@ def _register_delete_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_directive"))
async def delete_directive(
directive_id: str,
bank_id: str | None = None,
@@ -2101,7 +2159,7 @@ def _register_delete_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_directive"))
async def delete_directive(
directive_id: str,
) -> dict:
@@ -2144,7 +2202,7 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_memories"))
async def list_memories(
type: str | None = None,
q: str | None = None,
@@ -2188,7 +2246,7 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_memories"))
async def list_memories(
type: str | None = None,
q: str | None = None,
@@ -2234,7 +2292,7 @@ def _register_get_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_memory"))
async def get_memory(
memory_id: str,
bank_id: str | None = None,
@@ -2270,7 +2328,7 @@ def _register_get_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_memory"))
async def get_memory(
memory_id: str,
) -> dict:
@@ -2321,7 +2379,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
if config.include_bank_id_param:
@mcp.tool(description=_EDIT_DOC)
@mcp.tool(description=_EDIT_DOC, annotations=_tool_annotations("update_memory"))
async def update_memory(
memory_id: str,
text: str | None = None,
@@ -2367,7 +2425,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
else:
@mcp.tool(description=_EDIT_DOC)
@mcp.tool(description=_EDIT_DOC, annotations=_tool_annotations("update_memory"))
async def update_memory(
memory_id: str,
text: str | None = None,
@@ -2426,7 +2484,7 @@ def _register_invalidate_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPT
if config.include_bank_id_param:
@mcp.tool(description=_INVALIDATE_DOC)
@mcp.tool(description=_INVALIDATE_DOC, annotations=_tool_annotations("invalidate_memory"))
async def invalidate_memory(
memory_id: str,
reason: str | None = None,
@@ -2466,7 +2524,7 @@ def _register_invalidate_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPT
else:
@mcp.tool(description=_INVALIDATE_DOC)
@mcp.tool(description=_INVALIDATE_DOC, annotations=_tool_annotations("invalidate_memory"))
async def invalidate_memory(
memory_id: str,
reason: str | None = None,
@@ -2513,7 +2571,7 @@ def _register_list_documents(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_documents"))
async def list_documents(
q: str | None = None,
limit: int = 100,
@@ -2551,7 +2609,7 @@ def _register_list_documents(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_documents"))
async def list_documents(
q: str | None = None,
limit: int = 100,
@@ -2591,7 +2649,7 @@ def _register_get_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsC
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_document"))
async def get_document(
document_id: str,
bank_id: str | None = None,
@@ -2627,7 +2685,7 @@ def _register_get_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsC
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_document"))
async def get_document(
document_id: str,
) -> dict:
@@ -2665,7 +2723,7 @@ def _register_delete_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_document"))
async def delete_document(
document_id: str,
bank_id: str | None = None,
@@ -2699,7 +2757,7 @@ def _register_delete_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_document"))
async def delete_document(
document_id: str,
) -> dict:
@@ -2740,7 +2798,7 @@ def _register_list_operations(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_operations"))
async def list_operations(
status: str | None = None,
limit: int = 20,
@@ -2777,7 +2835,7 @@ def _register_list_operations(mcp: FastMCP, memory: MemoryEngine, config: MCPToo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_operations"))
async def list_operations(
status: str | None = None,
limit: int = 20,
@@ -2816,7 +2874,7 @@ def _register_get_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_operation"))
async def get_operation(
operation_id: str,
bank_id: str | None = None,
@@ -2850,7 +2908,7 @@ def _register_get_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_operation"))
async def get_operation(
operation_id: str,
) -> dict:
@@ -2886,7 +2944,7 @@ def _register_cancel_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("cancel_operation"))
async def cancel_operation(
operation_id: str,
bank_id: str | None = None,
@@ -2918,7 +2976,7 @@ def _register_cancel_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("cancel_operation"))
async def cancel_operation(
operation_id: str,
) -> dict:
@@ -2957,7 +3015,7 @@ def _register_list_tags(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConf
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_tags"))
async def list_tags(
q: str | None = None,
limit: int = 100,
@@ -2994,7 +3052,7 @@ def _register_list_tags(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConf
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("list_tags"))
async def list_tags(
q: str | None = None,
limit: int = 100,
@@ -3033,7 +3091,7 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_bank"))
async def get_bank(
bank_id: str | None = None,
) -> str:
@@ -3066,7 +3124,7 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_bank"))
async def get_bank() -> dict:
"""
Get the profile of this memory bank.
@@ -3096,7 +3154,7 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
def _register_get_bank_stats(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the get_bank_stats tool (multi-bank only)."""
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("get_bank_stats"))
async def get_bank_stats(
bank_id: str | None = None,
) -> str:
@@ -3169,7 +3227,7 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_bank"))
async def update_bank(
name: str | None = None,
mission: str | None = None,
@@ -3230,7 +3288,7 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("update_bank"))
async def update_bank(
name: str | None = None,
mission: str | None = None,
@@ -3293,7 +3351,7 @@ def _register_delete_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_bank"))
async def delete_bank(
bank_id: str | None = None,
) -> str:
@@ -3325,7 +3383,7 @@ def _register_delete_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("delete_bank"))
async def delete_bank() -> dict:
"""
Delete this memory bank and all its data.
@@ -3356,7 +3414,7 @@ def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
if config.include_bank_id_param:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_memories"))
async def clear_memories(
type: str | None = None,
bank_id: str | None = None,
@@ -3391,7 +3449,7 @@ def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTool
else:
@mcp.tool()
@mcp.tool(annotations=_tool_annotations("clear_memories"))
async def clear_memories(
type: str | None = None,
) -> dict:
+312 -19
View File
@@ -11,15 +11,17 @@ This module provides metrics for:
- Database connection pool metrics
"""
import asyncio
import importlib
import logging
import os
import re
_resource_mod = importlib.import_module("resource") if importlib.util.find_spec("resource") else None
import threading
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Callable, NamedTuple
from opentelemetry import metrics
from opentelemetry.exporter.prometheus import PrometheusMetricReader
@@ -75,6 +77,28 @@ LLM_DURATION_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60
# HTTP request duration buckets (millisecond-level for fast endpoints)
HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0)
# How often the backlog / queue-depth gauge caches are refreshed (seconds).
# The counts are aggregate COUNT queries, so a background task refreshes a
# cache and the observable gauges read from it — keeping the /metrics scrape
# path synchronous (the same reason the db-pool gauges read cached state).
BACKLOG_METRICS_REFRESH_SECONDS = 30
class _AsyncOpKey(NamedTuple):
"""Cache / label key for the async-operation queue gauge."""
tenant: str
operation_type: str
status: str
bank_id: str | None
class _BacklogKey(NamedTuple):
"""Cache / label key for the consolidation backlog and failed gauges."""
tenant: str
bank_id: str | None
def get_token_bucket(token_count: int) -> str:
"""
@@ -113,6 +137,27 @@ def get_token_bucket(token_count: int) -> str:
return "50k+"
# Template unbounded id segments before a path is used as the low-cardinality
# "endpoint" metric label. A raw per-bank path segment (e.g. user-123) would
# otherwise create one never-evicted OTel series per bank.
_METRIC_BANK_SEGMENT_RE = re.compile(r"(/banks/)[^/]+")
_METRIC_UUID_RE = re.compile(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
_METRIC_NUMERIC_ID_RE = re.compile(r"/\d+(?=/|$)")
def normalize_http_endpoint(path: str) -> str:
"""Template high-cardinality id segments in an HTTP path for safe metric labeling.
Collapses the "/banks/<id>" segment (any bank id, including non-numeric ones like
"user-123"), UUIDs, and numeric ids to placeholders so the "endpoint" metric label
has bounded cardinality. Analogous to get_token_bucket for token counts.
"""
path = _METRIC_BANK_SEGMENT_RE.sub(r"\g<1>{bank_id}", path)
path = _METRIC_UUID_RE.sub("/{id}", path)
path = _METRIC_NUMERIC_ID_RE.sub("/{id}", path)
return path
logger = logging.getLogger(__name__)
# Global meter instance
@@ -201,6 +246,19 @@ class MetricsCollectorBase:
"""Context manager to record operation duration and status."""
raise NotImplementedError
def record_operation_result(
self,
operation: str,
bank_id: str,
success: bool,
duration: float,
source: str = "api",
budget: str | None = None,
max_tokens: int | None = None,
):
"""Record a single completed operation with an explicit success label."""
raise NotImplementedError
def record_llm_call(
self,
provider: str,
@@ -254,6 +312,19 @@ class NoOpMetricsCollector(MetricsCollectorBase):
"""No-op context manager."""
yield
def record_operation_result(
self,
operation: str,
bank_id: str,
success: bool,
duration: float,
source: str = "api",
budget: str | None = None,
max_tokens: int | None = None,
):
"""No-op operation result recording."""
pass
def record_llm_call(
self,
provider: str,
@@ -361,6 +432,13 @@ class MetricsCollector(MetricsCollectorBase):
# DB pool metrics holder (set via set_db_pool)
self._db_pool: "asyncpg.Pool | None" = None
# Backlog / queue-depth gauge caches, refreshed by a background task
# (see _setup_backlog_metrics) so the scrape path stays synchronous.
self._async_ops_counts: dict[_AsyncOpKey, int] = {}
self._consolidation_backlog: dict[_BacklogKey, int] = {}
self._consolidation_failed: dict[_BacklogKey, int] = {}
self._backlog_task: "asyncio.Task | None" = None
@contextmanager
def record_operation(
self,
@@ -386,18 +464,6 @@ class MetricsCollector(MetricsCollectorBase):
max_tokens: Optional max tokens for the operation
"""
start_time = time.time()
attributes = {
"operation": operation,
"source": source,
"tenant": _get_tenant(),
}
if self._include_bank_id:
attributes["bank_id"] = bank_id
if budget:
attributes["budget"] = budget
if max_tokens:
attributes["max_tokens"] = str(max_tokens)
success = True
cancelled = False
try:
@@ -416,14 +482,51 @@ class MetricsCollector(MetricsCollectorBase):
raise
finally:
if not cancelled:
duration = time.time() - start_time
attributes["success"] = str(success).lower()
self.record_operation_result(
operation,
bank_id,
success=success,
duration=time.time() - start_time,
source=source,
budget=budget,
max_tokens=max_tokens,
)
# Record duration
self.operation_duration.record(duration, attributes)
def record_operation_result(
self,
operation: str,
bank_id: str,
success: bool,
duration: float,
source: str = "api",
budget: str | None = None,
max_tokens: int | None = None,
):
"""Record a single completed operation (duration + count) with a success label.
# Record operation count
self.operation_total.add(1, attributes)
Direct (non-context-manager) recording for code paths that need explicit
success control rather than the exception-based ``record_operation`` e.g.
the async worker, where deferrals/retries are not terminal outcomes and must
not be counted as completions.
"""
attributes = {
"operation": operation,
"source": source,
"tenant": _get_tenant(),
}
if self._include_bank_id:
attributes["bank_id"] = bank_id
if budget:
attributes["budget"] = budget
if max_tokens:
attributes["max_tokens"] = str(max_tokens)
attributes["success"] = str(success).lower()
# Record duration
self.operation_duration.record(duration, attributes)
# Record operation count
self.operation_total.add(1, attributes)
def record_llm_call(
self,
@@ -628,6 +731,10 @@ class MetricsCollector(MetricsCollectorBase):
"""
self._db_pool = pool
self._setup_db_pool_metrics()
from .config import get_config
if get_config().metrics_backlog_enabled:
self._setup_backlog_metrics()
def _setup_db_pool_metrics(self):
"""Set up observable gauges for database pool metrics."""
@@ -693,6 +800,192 @@ class MetricsCollector(MetricsCollectorBase):
unit="{connections}",
)
def _setup_backlog_metrics(self):
"""Observable gauges for the async-operation queue and the
consolidation backlog.
These mirror fields the bank-stats endpoint already computes
(``operations_by_status``, ``pending_consolidation``,
``failed_consolidation``) but expose them as scrapable gauges, so
queue depth and backlog can be trended and alerted on instead of only
polled per-bank over HTTP. The two motivating questions both come for
free here: "is the worker keeping up?" (async-op queue) and "is the
knowledge base caught up?" (consolidation backlog) — including the
``processing`` state, which is the only signal that surfaces a hung
operation stuck holding a worker slot.
Counts are aggregate ``COUNT`` queries, so a background task refreshes
a cache every ``BACKLOG_METRICS_REFRESH_SECONDS`` and these callbacks
read it keeping the scrape path synchronous, the same approach as
the db-pool gauges above.
"""
if self._backlog_task is not None:
return # already started for this collector
def get_async_operations(_options):
for key, value in list(self._async_ops_counts.items()):
attrs = {"tenant": key.tenant, "operation_type": key.operation_type, "status": key.status}
if key.bank_id is not None:
attrs["bank_id"] = key.bank_id
yield metrics.Observation(value, attrs)
def get_consolidation_backlog(_options):
for key, value in list(self._consolidation_backlog.items()):
attrs = {"tenant": key.tenant}
if key.bank_id is not None:
attrs["bank_id"] = key.bank_id
yield metrics.Observation(value, attrs)
def get_consolidation_failed(_options):
for key, value in list(self._consolidation_failed.items()):
attrs = {"tenant": key.tenant}
if key.bank_id is not None:
attrs["bank_id"] = key.bank_id
yield metrics.Observation(value, attrs)
self.meter.create_observable_gauge(
name="hindsight.async_operations",
callbacks=[get_async_operations],
description="Async operations in a non-terminal state, by operation_type and status "
"(pending=queued backlog, processing=in-flight, failed=stranded)",
unit="{operations}",
)
self.meter.create_observable_gauge(
name="hindsight.consolidation.backlog",
callbacks=[get_consolidation_backlog],
description="Source memories (experience/world) not yet consolidated into observations",
unit="{memories}",
)
self.meter.create_observable_gauge(
name="hindsight.consolidation.failed",
callbacks=[get_consolidation_failed],
description="Source memories whose consolidation permanently failed "
"(recoverable via the consolidation recovery endpoint)",
unit="{memories}",
)
# Drive the caches from a background task on the running loop.
# set_db_pool runs during async startup, so a loop is normally present;
# if not, the gauges simply stay empty rather than crashing collection.
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.warning("No running event loop; backlog metrics disabled")
return
# Process-lifetime task: there is no collector teardown hook to cancel it
# on, so it's torn down with the event loop at process shutdown. If a
# shutdown path is ever added, cancel self._backlog_task there.
self._backlog_task = loop.create_task(self._backlog_refresh_loop())
async def _backlog_refresh_loop(self):
"""Periodically refresh the backlog / queue-depth caches."""
while True:
try:
await self._refresh_backlog()
except Exception:
logger.debug("Backlog metrics refresh failed", exc_info=True)
await asyncio.sleep(BACKLOG_METRICS_REFRESH_SECONDS)
async def _refresh_backlog(self):
"""Recount the async-operation queue and consolidation backlog across
every provisioned Hindsight schema.
Per-bank labels are gated behind ``metrics_include_bank_id`` (off by
default) to keep cardinality bounded; when off, counts are aggregated
per tenant/schema. All SQL here is PostgreSQL-specific (``FILTER``,
``information_schema``), which is consistent with this collector
already being bound to an asyncpg pool.
"""
if self._db_pool is None:
return
async_ops: dict[_AsyncOpKey, int] = {}
backlog: dict[_BacklogKey, int] = {}
failed: dict[_BacklogKey, int] = {}
per_bank = self._include_bank_id
bank_sel = "bank_id, " if per_bank else ""
bank_grp = " GROUP BY bank_id" if per_bank else ""
async with self._db_pool.acquire() as conn:
# memory_units is the central per-tenant table; its presence marks a
# provisioned Hindsight schema.
schema_rows = await conn.fetch(
"SELECT table_schema FROM information_schema.tables WHERE table_name = 'memory_units'"
)
for schema_row in schema_rows:
schema = schema_row["table_schema"]
# Worker queue depth — mirrors operations_by_status, split by
# operation_type. Terminal states (completed/cancelled) are
# excluded on purpose: a gauge of finished work grows without
# bound and says nothing about current load.
# Index: idx_async_operations_status.
ops_grp = "operation_type, status" + (", bank_id" if per_bank else "")
try:
rows = await conn.fetch(
f"SELECT operation_type, status, {bank_sel}COUNT(*) AS count "
f'FROM "{schema}".async_operations '
"WHERE status IN ('pending', 'processing', 'failed') "
f"GROUP BY {ops_grp}"
)
for row in rows:
bank = row["bank_id"] if per_bank else None
key = _AsyncOpKey(schema, row["operation_type"] or "unknown", row["status"], bank)
async_ops[key] = async_ops.get(key, 0) + int(row["count"])
except Exception:
logger.debug("Async-ops queue query failed for schema %s", schema, exc_info=True)
# Consolidation backlog + stranded counts. Two separate COUNT(*)
# queries rather than one with two FILTERs — each WHERE matches a
# partial-index predicate exactly:
# idx_memory_units_unconsolidated WHERE consolidated_at IS NULL ...
# idx_memory_units_consolidation_failed WHERE consolidation_failed_at IS NOT NULL ...
# GROUP BY bank_id still composes — bank_id is each index's lead column.
#
# The backlog count runs with seqscan disabled in a scoped
# transaction. The partial index matches its predicate, but
# `consolidated_at IS NULL` is true for a large fraction of the
# table (every observation has a null consolidated_at), so the
# planner misjudges selectivity and otherwise seq-scans the whole
# (largest) table on every refresh — verified on a 114k-row table
# via EXPLAIN: seq scan ~92 ms vs index scan ~0.1 ms. SET LOCAL
# forces the index path and resets at transaction end. The failed
# count below needs no such nudge: `consolidation_failed_at IS NOT
# NULL` is rare, so its index is chosen on cost.
try:
async with conn.transaction():
await conn.execute("SET LOCAL enable_seqscan = off")
rows = await conn.fetch(
f"SELECT {bank_sel}COUNT(*) AS count "
f'FROM "{schema}".memory_units '
"WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')"
f"{bank_grp}"
)
for row in rows:
bank = row["bank_id"] if per_bank else None
key = _BacklogKey(schema, bank)
backlog[key] = backlog.get(key, 0) + int(row["count"])
except Exception:
logger.debug("Consolidation backlog query failed for schema %s", schema, exc_info=True)
try:
rows = await conn.fetch(
f"SELECT {bank_sel}COUNT(*) AS count "
f'FROM "{schema}".memory_units '
"WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')"
f"{bank_grp}"
)
for row in rows:
bank = row["bank_id"] if per_bank else None
key = _BacklogKey(schema, bank)
failed[key] = failed.get(key, 0) + int(row["count"])
except Exception:
logger.debug("Consolidation failed query failed for schema %s", schema, exc_info=True)
self._async_ops_counts = async_ops
self._consolidation_backlog = backlog
self._consolidation_failed = failed
# Global metrics collector instance (defaults to no-op)
_metrics_collector: MetricsCollectorBase = NoOpMetricsCollector()
@@ -20,9 +20,23 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from ..engine.schema import fq_table_explicit as fq_table
from ..metrics import get_metrics_collector
from .exceptions import DeferOperation, RetryTaskAt
from .stage import StageHolder, bind_holder
# Map DB operation_type -> metric `operation` label, collapsing the retain
# variants onto "retain" so async worker completions land on the same
# operation="retain" series the synchronous API path emits. Unknown types
# pass through unchanged.
_RETAIN_OP_TYPES = {"retain", "batch_retain", "file_convert_retain"}
def _metric_operation_label(operation_type: str | None) -> str:
if operation_type in _RETAIN_OP_TYPES:
return "retain"
return operation_type or "unknown"
if TYPE_CHECKING:
from hindsight_api.engine.db.base import DatabaseBackend, DatabaseConnection
from hindsight_api.extensions.tenant import TenantExtension
@@ -701,6 +715,24 @@ class WorkerPoller:
"""
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
# Operation metric (source="worker"): record on terminal outcomes only, so
# async worker throughput and latency (retain, consolidation and the other
# worker task types) are visible in Prometheus. Prefer the DB-authoritative
# operation_type.
#
# success semantics are deliberately narrow: success=false means the task
# raised out to the poller (an unexpected error, or retry-exhausted). It does
# NOT capture deterministic failures that the executor handles itself and
# returns from normally (file_convert_retain, non-retryable errors via
# memory_engine.execute_task) — those record success=true here. Treat this as
# a completion-throughput signal, not a failure-rate one: for authoritative
# failure visibility use the hindsight_async_operations{status="failed"} gauge,
# which reads each operation's final DB status.
op_label = _metric_operation_label(task.task_dict.get("operation_type") or task_type)
op_start = time.time()
metrics = get_metrics_collector()
# None = not a terminal outcome (deferred/retried) → no metric.
terminal_success: bool | None = None
# Bind the stage holder in this task's own contextvar scope so engine
# code running under us can update it via stage.set_stage(). If holder
@@ -717,14 +749,28 @@ class WorkerPoller:
task.task_dict["_schema"] = task.schema
await self._executor(task.task_dict)
logger.debug(f"Task {task.operation_id} execution finished")
terminal_success = True
except DeferOperation as e:
# Deferral is not a terminal outcome — do not record a completion.
await self._defer_operation(task.operation_id, e.exec_date, e.reason, task.schema)
except RetryTaskAt as e:
# Retry is not a terminal outcome — do not record a completion.
await self._schedule_retry(task.operation_id, e.retry_at, str(e), task.schema)
except Exception as e:
logger.error(f"Task {task.operation_id} failed: {e}")
traceback.print_exc()
await self._mark_failed(task.operation_id, str(e), task.schema)
terminal_success = False
# Record the metric outside the executor's exception scope so a metrics
# reporting failure can never be mistaken for a task failure and flip terminal state.
if terminal_success is not None:
try:
metrics.record_operation_result(
op_label, bank_id, success=terminal_success, duration=time.time() - op_start, source="worker"
)
except Exception:
logger.warning(f"Failed to record worker operation metric for {task.operation_id}", exc_info=True)
async def recover_own_tasks(self) -> int:
"""
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.8.2"
version = "0.8.3"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -63,7 +63,7 @@ dependencies = [
"langsmith>=0.6.3", # SSRF via tracing header injection fix
"protobuf>=6.33.5", # JSON recursion depth bypass fix
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
"cryptography>=46.0.6,<47", # Incomplete DNS name constraint enforcement fix; cap <47 — 47.0.0 SIGILLs on some ARM64 Linux VMs (Docker/Podman on Apple Silicon), pyca/cryptography#14733
"cryptography>=48.0.1", # GHSA-537c-gmf6-5ccf: bundled-OpenSSL OOB read fix needs >=48.0.1. Prior <47 cap (47.0.0 SIGILL on ARM64 Docker/Podman, pyca/cryptography#14733) lifted — 47/48/49 verified importing + RSA sign/verify cleanly on linux/arm64 (Docker on Apple Silicon) and native arm64 macOS; upstream issue closed unconfirmed.
"filelock>=3.20.1", # TOCTOU race condition fix
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
@@ -0,0 +1,244 @@
"""
Tests for the async-operation queue and consolidation backlog gauges
(``_setup_backlog_metrics`` / ``_refresh_backlog`` in metrics.py).
These gauges expose, as scrapable time-series, the same counts the bank-stats
endpoint already returns per bank (``operations_by_status``,
``pending_consolidation``, ``failed_consolidation``):
- ``hindsight_async_operations{operation_type,status}`` worker queue depth
(pending=backlog, processing=in-flight, failed=stranded)
- ``hindsight_consolidation_backlog`` source memories not yet consolidated
- ``hindsight_consolidation_failed`` source memories permanently failed
"""
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.metrics import MetricsCollector, _AsyncOpKey, _BacklogKey
class _FakeTxn:
async def __aenter__(self):
return None
async def __aexit__(self, *exc):
return False
class _FakeConn:
"""asyncpg-like connection whose fetch() is dispatched by SQL substring."""
def __init__(self, fetch_fn):
self._fetch_fn = fetch_fn
self.executed = []
async def fetch(self, sql, *args):
return self._fetch_fn(sql, *args)
async def execute(self, sql, *args):
self.executed.append(sql)
def transaction(self):
return _FakeTxn()
class _FakeAcquire:
def __init__(self, conn):
self._conn = conn
async def __aenter__(self):
return self._conn
async def __aexit__(self, *exc):
return False
class _FakePool:
def __init__(self, fetch_fn):
self._conn = _FakeConn(fetch_fn)
def acquire(self):
return _FakeAcquire(self._conn)
def _collector(include_bank_id=False):
mock_config = MagicMock()
mock_config.metrics_include_bank_id = include_bank_id
with (
patch("hindsight_api.metrics.get_meter", return_value=MagicMock()),
patch("hindsight_api.config.get_config", return_value=mock_config),
):
return MetricsCollector()
def _set_db_pool_with_backlog_enabled(collector, pool):
"""Call set_db_pool with the backlog flag forced on (it's off by default)."""
mock_config = MagicMock()
mock_config.metrics_backlog_enabled = True
with patch("hindsight_api.config.get_config", return_value=mock_config):
collector.set_db_pool(pool)
def _rows_for(sql):
"""Canned results, keyed off distinctive substrings of each query."""
if "information_schema.tables" in sql:
return [{"table_schema": "public"}]
if "async_operations" in sql:
return [
{"operation_type": "retain", "status": "pending", "count": 5},
{"operation_type": "consolidation", "status": "pending", "count": 12},
{"operation_type": "consolidation", "status": "processing", "count": 1},
{"operation_type": "consolidation", "status": "failed", "count": 2},
]
if "memory_units" in sql and "consolidated_at IS NULL" in sql:
return [{"count": 42}]
if "memory_units" in sql and "consolidation_failed_at IS NOT NULL" in sql:
return [{"count": 3}]
return []
@pytest.mark.asyncio
async def test_refresh_backlog_aggregates_queue_and_consolidation():
collector = _collector(include_bank_id=False)
collector._db_pool = _FakePool(lambda sql, *a: _rows_for(sql))
await collector._refresh_backlog()
# Worker queue depth keyed by (schema, operation_type, status, bank=None)
assert collector._async_ops_counts[("public", "retain", "pending", None)] == 5
assert collector._async_ops_counts[("public", "consolidation", "pending", None)] == 12
assert collector._async_ops_counts[("public", "consolidation", "processing", None)] == 1
assert collector._async_ops_counts[("public", "consolidation", "failed", None)] == 2
# Consolidation backlog (source memories), keyed by (schema, bank=None)
assert collector._consolidation_backlog[("public", None)] == 42
assert collector._consolidation_failed[("public", None)] == 3
@pytest.mark.asyncio
async def test_refresh_backlog_uses_index_matched_predicates_not_filter_scan():
"""Backlog/failed must be two separate COUNT(*) queries whose WHERE matches
a partial-index predicate exactly (no FILTER over a full-table scan), and
the queue query must exclude terminal statuses."""
captured = []
collector = _collector()
collector._db_pool = _FakePool(lambda sql, *a: (captured.append(sql), _rows_for(sql))[1])
await collector._refresh_backlog()
mem_queries = [s for s in captured if "memory_units" in s and "COUNT(*)" in s]
assert len(mem_queries) == 2 # split, not a single two-FILTER aggregate
assert all("FILTER" not in s for s in mem_queries)
assert any("consolidated_at IS NULL AND fact_type IN ('experience', 'world')" in s for s in mem_queries)
assert any("consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')" in s for s in mem_queries)
ops_sql = next(s for s in captured if "async_operations" in s and "GROUP BY" in s)
assert "status IN ('pending', 'processing', 'failed')" in ops_sql
assert "completed" not in ops_sql and "cancelled" not in ops_sql
@pytest.mark.asyncio
async def test_backlog_count_runs_with_seqscan_disabled():
"""`consolidated_at IS NULL` is true for a large fraction of the table, so
the planner misjudges selectivity and won't use the partial index without a
nudge the backlog count must issue SET LOCAL enable_seqscan=off."""
collector = _collector()
pool = _FakePool(lambda sql, *a: _rows_for(sql))
collector._db_pool = pool
await collector._refresh_backlog()
assert any("enable_seqscan" in s.lower() and "off" in s.lower() for s in pool._conn.executed)
# the result is still correct under the nudge
assert collector._consolidation_backlog[("public", None)] == 42
@pytest.mark.asyncio
async def test_refresh_backlog_per_bank_labels_and_group_by_when_enabled():
"""With metrics_include_bank_id on, bank_id enters the cache key and the
SQL switches to GROUP BY bank_id."""
captured = []
def fetch(sql, *a):
captured.append(sql)
if "information_schema.tables" in sql:
return [{"table_schema": "public"}]
if "async_operations" in sql:
return [{"operation_type": "retain", "status": "pending", "bank_id": "bankA", "count": 4}]
if "memory_units" in sql and "consolidated_at IS NULL" in sql:
return [{"bank_id": "bankA", "count": 11}]
if "memory_units" in sql and "consolidation_failed_at IS NOT NULL" in sql:
return [{"bank_id": "bankA", "count": 2}]
return []
collector = _collector(include_bank_id=True)
collector._db_pool = _FakePool(fetch)
await collector._refresh_backlog()
assert collector._async_ops_counts[("public", "retain", "pending", "bankA")] == 4
assert collector._consolidation_backlog[("public", "bankA")] == 11
assert collector._consolidation_failed[("public", "bankA")] == 2
# bank_id must be grouped in every per-bank count query
assert all("GROUP BY bank_id" in s for s in captured if "memory_units" in s and "COUNT(*)" in s)
def test_gauges_register_and_emit_cached_values_without_bank_id():
collector = _collector(include_bank_id=False)
# Sync call: no running loop, so gauges register but no background task spawns.
_set_db_pool_with_backlog_enabled(collector, MagicMock())
gauges = {
c.kwargs["name"]: c.kwargs["callbacks"][0]
for c in collector.meter.create_observable_gauge.call_args_list
if "callbacks" in c.kwargs
}
assert "hindsight.async_operations" in gauges
assert "hindsight.consolidation.backlog" in gauges
assert "hindsight.consolidation.failed" in gauges
collector._async_ops_counts = {
_AsyncOpKey("public", "retain", "pending", None): 7,
_AsyncOpKey("public", "consolidation", "processing", None): 1,
}
collector._consolidation_backlog = {_BacklogKey("public", None): 9}
obs = list(gauges["hindsight.async_operations"](None))
by_label = {(o.attributes["operation_type"], o.attributes["status"]): o.value for o in obs}
assert by_label[("retain", "pending")] == 7
assert by_label[("consolidation", "processing")] == 1
assert all("bank_id" not in o.attributes for o in obs) # cardinality guard
backlog_obs = list(gauges["hindsight.consolidation.backlog"](None))
assert backlog_obs[0].value == 9
assert backlog_obs[0].attributes["tenant"] == "public"
def test_gauge_emits_bank_id_attribute_when_present():
collector = _collector(include_bank_id=True)
_set_db_pool_with_backlog_enabled(collector, MagicMock())
gauges = {
c.kwargs["name"]: c.kwargs["callbacks"][0]
for c in collector.meter.create_observable_gauge.call_args_list
if "callbacks" in c.kwargs
}
collector._consolidation_backlog = {_BacklogKey("public", "bankA"): 4}
obs = list(gauges["hindsight.consolidation.backlog"](None))
assert obs[0].value == 4
assert obs[0].attributes["bank_id"] == "bankA"
def test_backlog_gauges_not_registered_when_flag_disabled():
"""Backlog metrics are off by default: set_db_pool must not register the
gauges unless metrics_backlog_enabled is set."""
collector = _collector()
mock_config = MagicMock()
mock_config.metrics_backlog_enabled = False
with patch("hindsight_api.config.get_config", return_value=mock_config):
collector.set_db_pool(MagicMock())
names = [
c.kwargs.get("name") for c in collector.meter.create_observable_gauge.call_args_list if "callbacks" in c.kwargs
]
assert "hindsight.async_operations" not in names
assert "hindsight.consolidation.backlog" not in names
assert "hindsight.consolidation.failed" not in names
assert collector._backlog_task is None
@@ -0,0 +1,109 @@
"""Tests for ``CODEX_HOME`` resolution of the Codex ``auth.json`` location.
Codex stores its OAuth credentials under a configurable home directory. The
canonical ``@openai/codex`` CLI honors the ``CODEX_HOME`` environment variable
and falls back to ``~/.codex``. Hindsight's Codex auth/LLM/embeddings paths
must resolve the same way so that a user who relocates ``CODEX_HOME`` is still
authenticated.
"""
import json
from pathlib import Path
from hindsight_api.engine.providers.codex_auth import (
CodexAuthManager,
default_codex_auth_file,
)
from hindsight_api.engine.providers.codex_llm import CodexLLM
def _write_auth(auth_dir: Path, access_token: str = "at-test") -> Path:
"""Write a minimal chatgpt-mode auth.json under ``auth_dir``."""
auth_dir.mkdir(parents=True, exist_ok=True)
auth_file = auth_dir / "auth.json"
auth_file.write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": access_token,
"refresh_token": "rt-test",
"account_id": "acct-test",
},
}
)
)
return auth_file
# ---------------------------------------------------------------------------
# default_codex_auth_file()
# ---------------------------------------------------------------------------
def test_default_auth_file_falls_back_to_home_codex_when_unset(tmp_path, monkeypatch):
monkeypatch.delenv("CODEX_HOME", raising=False)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert default_codex_auth_file() == tmp_path / ".codex" / "auth.json"
def test_default_auth_file_honors_codex_home_when_set(tmp_path, monkeypatch):
codex_home = tmp_path / "custom-codex"
monkeypatch.setenv("CODEX_HOME", str(codex_home))
assert default_codex_auth_file() == codex_home / "auth.json"
def test_default_auth_file_empty_codex_home_falls_back(tmp_path, monkeypatch):
"""An empty ``CODEX_HOME`` is treated as unset (matches shell semantics)."""
monkeypatch.setenv("CODEX_HOME", "")
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert default_codex_auth_file() == tmp_path / ".codex" / "auth.json"
def test_default_auth_file_resolved_lazily(tmp_path, monkeypatch):
"""The env var is read on each call, not cached at import time."""
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "a"))
assert default_codex_auth_file() == tmp_path / "a" / "auth.json"
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "b"))
assert default_codex_auth_file() == tmp_path / "b" / "auth.json"
# ---------------------------------------------------------------------------
# CodexAuthManager.from_file() — honors CODEX_HOME by default
# ---------------------------------------------------------------------------
def test_auth_manager_from_file_uses_codex_home(tmp_path, monkeypatch):
codex_home = tmp_path / "custom-codex"
_write_auth(codex_home, access_token="at-from-codex-home")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
mgr = CodexAuthManager.from_file()
assert mgr.access_token == "at-from-codex-home"
assert mgr._auth_file == codex_home / "auth.json"
# ---------------------------------------------------------------------------
# CodexLLM — loads credentials from CODEX_HOME
# ---------------------------------------------------------------------------
def test_codex_llm_loads_from_codex_home(tmp_path, monkeypatch):
codex_home = tmp_path / "custom-codex"
_write_auth(codex_home, access_token="at-llm")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
llm = CodexLLM(
provider="codex",
api_key="ignored",
base_url="",
model="gpt-5-codex",
)
assert llm.access_token == "at-llm"
assert llm._auth_file == codex_home / "auth.json"
@@ -25,6 +25,7 @@ def setup_test_env():
"HINDSIGHT_API_LLM_MODEL",
"HINDSIGHT_API_LLM_REASONING_EFFORT",
"HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER",
"HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER",
"HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY",
"HINDSIGHT_API_DATABASE_URL",
"HINDSIGHT_API_MIGRATION_DATABASE_URL",
@@ -452,6 +453,53 @@ def test_llm_output_language_empty_string_is_unset(monkeypatch):
assert config.llm_output_language is None
def test_markitdown_ocr_defaults_disabled(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.file_parser_markitdown_ocr_enabled is False
def test_markitdown_ocr_does_not_fall_back_to_main_llm_config(monkeypatch):
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT, HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED", "true")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "anthropic")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "main-key")
monkeypatch.setenv("HINDSIGHT_API_LLM_BASE_URL", "https://main.example/v1")
monkeypatch.setenv("HINDSIGHT_API_LLM_MODEL", "main-vision-model")
config = HindsightConfig.from_env()
assert config.file_parser_markitdown_ocr_enabled is True
assert config.file_parser_markitdown_ocr_api_key is None
assert config.file_parser_markitdown_ocr_base_url is None
assert config.file_parser_markitdown_ocr_model is None
assert config.file_parser_markitdown_ocr_prompt == DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
def test_markitdown_ocr_uses_explicit_config(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED", "true")
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY", "parser-key")
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL", "https://parser.example/v1")
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL", "parser-vision-model")
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT", "Extract this document exactly.")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "main-key")
monkeypatch.setenv("HINDSIGHT_API_LLM_BASE_URL", "https://main.example/v1")
monkeypatch.setenv("HINDSIGHT_API_LLM_MODEL", "main-vision-model")
config = HindsightConfig.from_env()
assert config.file_parser_markitdown_ocr_enabled is True
assert config.file_parser_markitdown_ocr_api_key == "parser-key"
assert config.file_parser_markitdown_ocr_base_url == "https://parser.example/v1"
assert config.file_parser_markitdown_ocr_model == "parser-vision-model"
assert config.file_parser_markitdown_ocr_prompt == "Extract this document exactly."
def test_llm_reasoning_effort_defaults_to_low(monkeypatch):
from hindsight_api.config import HindsightConfig
@@ -580,3 +628,81 @@ def test_bedrock_service_tier_rejects_invalid_value(monkeypatch):
assert "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER" in error_message
assert "standard" in error_message
assert "'standard' is not a valid Bedrock service tier" in error_message
# ---------------------------------------------------------------------------
# Gemini service tier (HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER)
# ---------------------------------------------------------------------------
def test_gemini_service_tier_defaults_to_none(monkeypatch):
"""Gemini service tier defaults to None (standard tier) when unset."""
from hindsight_api.config import HindsightConfig
monkeypatch.delenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.llm_gemini_service_tier is None
def test_gemini_service_tier_flex(monkeypatch):
"""Flex tier is accepted for Gemini."""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "flex")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key")
config = HindsightConfig.from_env()
assert config.llm_gemini_service_tier == "flex"
def test_gemini_service_tier_accepts_mixed_case_provider(monkeypatch):
"""Gemini tier parsing follows provider's case-insensitive handling."""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "flex")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "Gemini")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key")
config = HindsightConfig.from_env()
assert config.llm_gemini_service_tier == "flex"
def test_gemini_service_tier_rejects_invalid_value(monkeypatch):
"""Unknown Gemini service tiers are rejected early."""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key")
with pytest.raises(ValueError) as exc_info:
HindsightConfig.from_env()
error_message = str(exc_info.value)
assert "HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER" in error_message
assert "standard" in error_message
def test_gemini_service_tier_ignored_for_non_gemini_provider(monkeypatch):
"""Invalid Gemini-only tiers do not break unrelated providers."""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard")
config = HindsightConfig.from_env()
assert config.llm_gemini_service_tier is None
def test_gemini_service_tier_empty_env_is_unset(monkeypatch):
"""Empty env values are treated as unset for templated deployments."""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.llm_gemini_service_tier is None
@@ -124,6 +124,10 @@ def test_openai_codex_provider_uses_codex_oauth_token_and_configured_batch_size(
)
monkeypatch.setenv("HOME", str(tmp_path))
# Codex auth resolves via CODEX_HOME first (falling back to ~/.codex), so a
# CODEX_HOME leaking in from the runner's environment would point auth.json
# away from the tmp_path fixture. Pin resolution to the patched HOME.
monkeypatch.delenv("CODEX_HOME", raising=False)
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
os.environ["HINDSIGHT_API_EMBEDDINGS_PROVIDER"] = "openai-codex"
os.environ["HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"] = "text-embedding-3-small"
+95 -1
View File
@@ -911,7 +911,7 @@ class TestPrecheckHttpWiring:
def _build_app(validator):
"""Mirror the precheck wiring from ``hindsight_api.api.http`` in a
standalone FastAPI app."""
from fastapi import Depends, FastAPI, HTTPException
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, model_validator
from hindsight_api.extensions import PrecheckContext
@@ -952,12 +952,23 @@ class TestPrecheckHttpWiring:
def _precheck_for(operation: str):
async def _dep(
bank_id: str,
request: Request,
request_context: RequestContext = Depends(_request_context),
) -> None:
cl_header = request.headers.get("content-length")
content_length: int | None = None
if cl_header is not None:
try:
parsed = int(cl_header)
except ValueError:
parsed = -1
if parsed >= 0:
content_length = parsed
ctx = PrecheckContext(
operation=operation,
bank_id=bank_id,
request_context=request_context,
content_length=content_length,
)
result = await validator.precheck(ctx)
if not result.allowed:
@@ -1081,3 +1092,86 @@ class TestPrecheckHttpWiring:
resp = client.get("/v1/default/banks/precheck-bank/memories/list")
assert resp.status_code == 200
assert len(validator.precheck_calls) == 0
def test_precheck_context_carries_content_length(self):
"""Content-Length header is exposed to the precheck so a validator
can make size-aware decisions (e.g. upper-bound cost estimate)
before the body is deserialised."""
validator = RecordingPrecheckValidator(reject=False)
app, _ = self._build_app(validator)
client = TestClient(app)
# Body must contain at least 500 'x' bytes; check the surfaced
# Content-Length is within a tight band around that floor (allows
# for JSON envelope + httpx's serialisation choices without
# depending on exact byte counts).
payload = {"items": [{"content": "x" * 500}]}
resp = client.post(
"/v1/default/banks/precheck-bank/memories",
json=payload,
)
assert resp.status_code == 200
assert len(validator.precheck_calls) == 1
ctx = validator.precheck_calls[0]
assert ctx.content_length is not None
assert 500 <= ctx.content_length <= 600
def test_precheck_context_content_length_zero_is_not_none(self):
"""An empty POST body has Content-Length: 0. That should surface
as the int 0, not None None means 'unknown', 0 means 'known to
be empty'."""
validator = RecordingPrecheckValidator(reject=False)
app, _ = self._build_app(validator)
client = TestClient(app)
# Empty body fails Pydantic parse (422), but precheck runs first
# and records the Content-Length.
client.post(
"/v1/default/banks/precheck-bank/memories",
content=b"",
headers={"content-type": "application/json"},
)
assert len(validator.precheck_calls) >= 1
ctx = validator.precheck_calls[-1]
assert ctx.content_length == 0
@pytest.mark.asyncio
async def test_precheck_context_content_length_none_when_header_missing(self):
"""When the Content-Length header isn't set (e.g. chunked transfer
encoding) the validator sees None, not a crash and not a default 0."""
from starlette.requests import Request as _StarletteRequest
from hindsight_api.extensions import PrecheckContext
from hindsight_api.models import RequestContext
validator = RecordingPrecheckValidator(reject=False)
# Replicate the wiring's parse step inline so the test exercises
# the same code-path semantics introduced in
# ``hindsight_api.api.http._precheck_dep``.
scope = {
"type": "http",
"method": "POST",
"path": "/v1/default/banks/bank-x/memories",
"headers": [], # no content-length
"query_string": b"",
}
req = _StarletteRequest(scope)
cl_header = req.headers.get("content-length")
content_length: int | None = None
if cl_header is not None:
try:
parsed = int(cl_header)
except ValueError:
parsed = -1
if parsed >= 0:
content_length = parsed
ctx = PrecheckContext(
operation="retain",
bank_id="bank-x",
request_context=RequestContext(),
content_length=content_length,
)
await validator.precheck(ctx)
assert validator.precheck_calls[-1].content_length is None
@@ -77,6 +77,27 @@ async def test_dry_run_extracts_without_persisting(api_client, memory):
assert after["total"] == before["total"]
@pytest.mark.asyncio
async def test_dry_run_rejects_empty_content(api_client, memory):
"""Empty/whitespace-only content is rejected by request validation (422) before the
billable LLM extraction call runs matching retain (RetainItem.content) and recall
(RecallRequest.query), which already reject empty input."""
bank_id = f"dryrun-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext())
before = await memory.list_memory_units(bank_id=bank_id, request_context=RequestContext())
for content in ("", " ", "\n\t "):
resp = await api_client.post(
f"/v1/default/banks/{bank_id}/memories/dry-run-extract",
json={"content": content},
)
assert resp.status_code == 422, resp.text
# Rejected before extraction: nothing was persisted.
after = await memory.list_memory_units(bank_id=bank_id, request_context=RequestContext())
assert after["total"] == before["total"]
@pytest.mark.asyncio
async def test_dry_run_disabled_returns_404(api_client, memory):
"""With HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=false the endpoint is removed (returns 404)."""
@@ -346,6 +346,149 @@ async def test_markitdown_converter():
assert "test document" in result.lower() or "multiple lines" in result.lower()
def test_markitdown_converter_does_not_enable_ocr_by_default(monkeypatch):
"""Markitdown should keep its local/default behavior unless OCR is explicitly enabled."""
import markitdown
from hindsight_api.engine.parsers import MarkitdownParser
calls = []
class FakeMarkItDown:
def __init__(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown)
MarkitdownParser()
assert calls == [{}]
@pytest.mark.asyncio
async def test_markitdown_image_without_ocr_has_actionable_error(monkeypatch):
"""Image uploads should explain that MarkItDown OCR is disabled instead of surfacing a low-level error."""
import markitdown
from hindsight_api.engine.parsers import MarkitdownParser
class FakeMarkItDown:
def __init__(self, **kwargs):
pass
def convert(self, path):
raise AssertionError("MarkItDown should not be called when image OCR is disabled")
monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown)
parser = MarkitdownParser()
with pytest.raises(RuntimeError, match="Image OCR is not enabled for the markitdown parser"):
await parser.convert(b"\x89PNG\r\n\x1a\n", "screenshot.png")
def test_markitdown_converter_can_enable_ocr(monkeypatch):
"""When enabled, Markitdown receives an OpenAI-compatible client, model, and OCR prompt."""
import markitdown
import openai
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
from hindsight_api.engine.parsers import MarkitdownParser
markitdown_calls = []
openai_calls = []
class FakeMarkItDown:
def __init__(self, **kwargs):
markitdown_calls.append(kwargs)
class FakeOpenAI:
def __init__(self, **kwargs):
openai_calls.append(kwargs)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown)
monkeypatch.setattr(openai, "OpenAI", FakeOpenAI)
MarkitdownParser(
ocr_enabled=True,
ocr_api_key="parser-key",
ocr_base_url="https://vision.example/v1",
ocr_model="vision-model",
)
assert openai_calls == [
{
"api_key": "parser-key",
"base_url": "https://vision.example/v1",
}
]
assert markitdown_calls[0]["llm_client"].__class__ is FakeOpenAI
assert markitdown_calls[0]["llm_model"] == "vision-model"
assert markitdown_calls[0]["llm_prompt"] == DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
def test_markitdown_converter_requires_model_when_ocr_enabled(monkeypatch):
"""OCR should fail fast when enabled without a model."""
import markitdown
from hindsight_api.engine.parsers import MarkitdownParser
class FakeMarkItDown:
def __init__(self, **kwargs):
pass
monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown)
with pytest.raises(ValueError, match="no model"):
MarkitdownParser(ocr_enabled=True, ocr_api_key="parser-key")
def test_markitdown_converter_requires_base_url_when_ocr_enabled(monkeypatch):
"""OCR should fail fast when enabled without a dedicated OpenAI-compatible endpoint."""
import markitdown
from hindsight_api.engine.parsers import MarkitdownParser
class FakeMarkItDown:
def __init__(self, **kwargs):
pass
monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown)
with pytest.raises(ValueError, match="no base URL"):
MarkitdownParser(ocr_enabled=True, ocr_api_key="parser-key", ocr_model="vision-model")
def test_markitdown_converter_reports_missing_openai_when_ocr_enabled(monkeypatch):
"""Missing OpenAI SDK should not be reported as missing MarkItDown."""
import builtins
import markitdown
from hindsight_api.engine.parsers import MarkitdownParser
real_import = builtins.__import__
class FakeMarkItDown:
def __init__(self, **kwargs):
pass
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "openai":
raise ImportError("no openai")
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown)
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(RuntimeError, match="openai package is required"):
MarkitdownParser(
ocr_enabled=True,
ocr_api_key="parser-key",
ocr_base_url="https://vision.example/v1",
ocr_model="vision-model",
)
@pytest.mark.asyncio
async def test_converter_registry():
"""Test file parser registry."""
@@ -12,6 +12,8 @@ import subprocess
import tempfile
import time
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
import httpx
import pytest
@@ -21,6 +23,7 @@ logger = logging.getLogger(__name__)
try:
from testcontainers.core.container import DockerContainer
from testcontainers.core.docker_client import DockerClient as _DockerClient
_has_testcontainers = True
except ImportError:
@@ -38,6 +41,8 @@ SEAWEEDFS_S3_PORT = 8333
TEST_BUCKET = "hindsight-test"
ACCESS_KEY = "test_access_key"
SECRET_KEY = "test_secret_key"
_PORT_MAPPING_RETRY_TIMEOUT_SECONDS = 10.0
_PORT_MAPPING_RETRY_INTERVAL_SECONDS = 0.1
# SeaweedFS S3 IAM config granting full access to our test credentials
_S3_CONFIG = {
@@ -64,6 +69,33 @@ def _docker_available() -> bool:
return False
if _has_testcontainers:
@contextmanager
def _retry_testcontainers_port_mapping() -> Iterator[None]:
original_port = _DockerClient.port
def port_with_retry(self: _DockerClient, container_id: str, port: int) -> str:
deadline = time.monotonic() + _PORT_MAPPING_RETRY_TIMEOUT_SECONDS
while True:
try:
return original_port(self, container_id, port)
except ConnectionError:
# Docker Desktop can report a container as running before its
# published port appears in NetworkSettings.Ports. This affects
# both Ryuk's 8080 lookup inside testcontainers and the
# SeaweedFS S3 port lookup below.
if time.monotonic() >= deadline:
raise
time.sleep(_PORT_MAPPING_RETRY_INTERVAL_SECONDS)
_DockerClient.port = port_with_retry
try:
yield
finally:
_DockerClient.port = original_port
def _wait_for_seaweedfs(endpoint: str, timeout: int = 30) -> None:
"""Poll SeaweedFS S3 endpoint until ready."""
deadline = time.time() + timeout
@@ -101,11 +133,11 @@ def seaweedfs_container():
.with_command(f"server -s3 -s3.port={SEAWEEDFS_S3_PORT} -s3.config=/etc/seaweedfs/s3.json -ip.bind=0.0.0.0")
)
container.start()
try:
host = container.get_container_host_ip()
port = container.get_exposed_port(SEAWEEDFS_S3_PORT)
with _retry_testcontainers_port_mapping():
container.start()
host = container.get_container_host_ip()
port = container.get_exposed_port(SEAWEEDFS_S3_PORT)
endpoint = f"http://{host}:{port}"
_wait_for_seaweedfs(endpoint, timeout=240)
@@ -0,0 +1,103 @@
"""Plumbing tests for the Gemini service tier flag."""
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.engine.llm_wrapper import LLMConfig
def test_llm_config_threads_gemini_service_tier_to_provider_impl():
"""End-to-end: LLMConfig -> create_llm_provider -> GeminiLLM carries the tier."""
pytest.importorskip("google.genai")
with patch("google.genai.Client", return_value=MagicMock()):
llm = LLMConfig(
provider="gemini",
api_key="fake-key",
base_url="",
model="gemini-2.5-flash",
gemini_service_tier="flex",
)
assert llm._provider_impl._service_tier == "flex"
def test_llm_provider_from_env_validates_gemini_service_tier(monkeypatch):
"""Direct env construction rejects the same invalid tiers as HindsightConfig."""
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.llm_wrapper import LLMProvider
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key")
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard")
clear_config_cache()
with pytest.raises(ValueError, match="HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER"):
LLMProvider.from_env()
clear_config_cache()
def test_llm_provider_from_env_ignores_gemini_tier_for_non_gemini(monkeypatch):
"""Invalid Gemini-only tier env values do not break other providers."""
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.llm_wrapper import LLMProvider
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard")
clear_config_cache()
provider = LLMProvider.from_env()
assert provider.gemini_service_tier is None
clear_config_cache()
def test_llm_provider_from_env_keeps_lightweight_loader(monkeypatch):
"""Reading the Gemini tier must not construct the full application config."""
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.llm_wrapper import LLMProvider
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key")
monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "flex")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS", "1000")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_CHUNK_SIZE", "2000")
clear_config_cache()
with patch("google.genai.Client", return_value=MagicMock()):
provider = LLMProvider.from_env()
assert provider.gemini_service_tier == "flex"
clear_config_cache()
def test_llm_provider_constructor_validates_gemini_service_tier():
"""Direct Gemini construction rejects invalid tiers before API calls."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with pytest.raises(ValueError, match="HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER"):
LLMProvider(
provider="gemini",
api_key="fake-key",
base_url="",
model="gemini-2.5-flash",
gemini_service_tier="standard",
)
def test_vertexai_ignores_gemini_service_tier():
"""The Gemini-only tier flag is not forwarded to Vertex AI providers."""
from hindsight_api.engine.llm_wrapper import create_llm_provider
with patch("hindsight_api.engine.providers.GeminiLLM") as mock_gemini:
create_llm_provider(
provider="vertexai",
api_key="",
base_url="",
model="gemini-2.5-flash",
reasoning_effort="low",
gemini_service_tier="flex",
)
assert mock_gemini.call_args.kwargs["gemini_service_tier"] is None
@@ -0,0 +1,71 @@
"""Regression test: list_banks must apply the same disposition + mission
config overlay that get_bank_profile applies.
Bug (reproduced live against 0.8.1): for a bank whose disposition and
mission were evolved/overridden via bank *config* (the banks.config JSONB:
reflect_mission, disposition_skepticism/literalism/empathy), the single-bank
get path returns the real values while the list path returns the stale legacy
DB-column defaults ({skepticism:3, literalism:3, empathy:3} and "").
Root cause: MemoryEngine.get_bank_profile overlays the resolved bank config
on top of the legacy banks.disposition/banks.mission columns, but
MemoryEngine.list_banks returned bank_utils.list_banks rows straight from
those columns with no overlay. The two endpoints disagreed for the same bank.
This test sets disposition + mission through the config path (so the legacy
columns keep their defaults) and asserts list_banks agrees with
get_bank_profile for that bank.
Runs via: uv run pytest tests/test_list_banks_config_overlay.py -v
"""
from __future__ import annotations
import pytest
from hindsight_api.models import RequestContext
@pytest.mark.asyncio
async def test_list_banks_overlays_config_disposition_and_mission(memory):
bank_id = "list_banks_config_overlay_bank"
request_context = RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False)
# Values that differ from the 3/3/3 defaults on every trait, and a
# clearly non-empty mission, so a stale-default regression is unmissable.
overrides = {
"reflect_mission": "I am the shared long-term memory for this regression test.",
"disposition_skepticism": 4,
"disposition_literalism": 5,
"disposition_empathy": 2,
}
try:
# Create the bank. Its legacy banks.disposition/banks.mission columns
# keep their defaults (3/3/3 and "") — the real values live in config.
await memory.get_bank_profile(bank_id, request_context=request_context)
# Set disposition + mission via the *config* path (banks.config JSONB),
# exactly the path that triggered the live bug.
await memory._config_resolver.update_bank_config(bank_id, overrides, request_context)
# Source of truth: the single-bank get path already overlays config.
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert profile["mission"] == overrides["reflect_mission"]
assert profile["disposition"] == {"skepticism": 4, "literalism": 5, "empathy": 2}
# The list path must agree with the get path for this bank.
banks = await memory.list_banks(request_context=request_context)
entry = next((b for b in banks if b["bank_id"] == bank_id), None)
assert entry is not None, f"bank {bank_id!r} not present in list_banks output"
assert entry["mission"] == profile["mission"], (
"list_banks returned a different mission than get_bank_profile: "
f"list={entry['mission']!r} get={profile['mission']!r}"
)
assert entry["disposition"] == profile["disposition"], (
"list_banks returned a different disposition than get_bank_profile: "
f"list={entry['disposition']!r} get={profile['disposition']!r}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,82 @@
"""
Regression test for the hard timeout on the LiteLLM provider.
A completion that never returns a connection held open with no token
progress, or one straggler inside a concurrent ``asyncio.gather`` fan-out
must not block forever. ``call`` / ``call_with_tools`` wrap the request in
``asyncio.wait_for`` so it is cancelled after ``timeout`` seconds and surfaced
as a retryable ``TimeoutError`` instead of pinning a worker slot and a
concurrency permit indefinitely.
"""
import asyncio
import time
import pytest
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
def _make_provider(timeout: float | None) -> LiteLLMLLM:
return LiteLLMLLM(
provider="litellm",
api_key="unused",
base_url="http://localhost:0/v1",
model="litellm_proxy/test-model",
timeout=timeout,
)
async def test_call_cancels_hung_completion(monkeypatch):
"""A hung ``_acompletion`` is cancelled per attempt and raises TimeoutError."""
provider = _make_provider(timeout=0.1)
calls = 0
async def _hang(**kwargs):
nonlocal calls
calls += 1
await asyncio.Event().wait() # never resolves
monkeypatch.setattr(provider, "_acompletion", _hang)
started = time.monotonic()
with pytest.raises((TimeoutError, asyncio.TimeoutError)):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=1,
initial_backoff=0.01,
max_backoff=0.01,
)
elapsed = time.monotonic() - started
# max_retries=1 -> attempts 0 and 1, each bounded by the timeout.
assert calls == 2
# Bounded by ~2 * timeout + backoff — nowhere near hanging forever.
assert elapsed < 2.0
async def test_call_with_tools_cancels_hung_completion(monkeypatch):
provider = _make_provider(timeout=0.1)
async def _hang(**kwargs):
await asyncio.Event().wait()
monkeypatch.setattr(provider, "_acompletion", _hang)
with pytest.raises((TimeoutError, asyncio.TimeoutError)):
await provider.call_with_tools(
messages=[{"role": "user", "content": "hi"}],
tools=[],
max_retries=0,
initial_backoff=0.01,
max_backoff=0.01,
)
async def test_unset_timeout_falls_back_to_default(monkeypatch):
"""``None`` must resolve to a finite default — never ``None``, which would
make ``asyncio.wait_for`` wait forever and reintroduce the hang."""
monkeypatch.delenv(ENV_LLM_TIMEOUT, raising=False)
provider = _make_provider(timeout=None)
assert provider.timeout == DEFAULT_LLM_TIMEOUT
@@ -120,7 +120,7 @@ async def test_anthropic_no_extra_body_omits_key():
# ─── Gemini ───────────────────────────────────────────────────────────────────
def _make_gemini_provider(extra_body=None):
def _make_gemini_provider(extra_body=None, gemini_service_tier=None):
pytest.importorskip("google.genai")
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
@@ -132,6 +132,7 @@ def _make_gemini_provider(extra_body=None):
base_url="",
model="gemini-2.5-flash",
extra_body=extra_body,
gemini_service_tier=gemini_service_tier,
)
provider._client = MagicMock()
return provider
@@ -176,6 +177,34 @@ async def test_gemini_explicit_temperature_overrides_extra_body():
assert config_arg.temperature == 0.9
@pytest.mark.asyncio
async def test_gemini_service_tier_applies_to_http_options_extra_body():
"""The native Gemini service tier flag reaches GenerateContentConfig."""
provider = _make_gemini_provider(gemini_service_tier="flex")
provider._client.aio.models.generate_content = AsyncMock(return_value=_fake_gemini_response())
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test")
config_arg = provider._client.aio.models.generate_content.call_args.kwargs.get("config")
assert config_arg.http_options.extra_body["service_tier"] == "flex"
@pytest.mark.asyncio
async def test_gemini_extra_body_service_tier_takes_precedence():
"""The explicit extra_body escape hatch wins over the native flag."""
provider = _make_gemini_provider(
extra_body={"http_options": {"extra_body": {"service_tier": "standard"}}},
gemini_service_tier="flex",
)
provider._client.aio.models.generate_content = AsyncMock(return_value=_fake_gemini_response())
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test")
config_arg = provider._client.aio.models.generate_content.call_args.kwargs.get("config")
assert config_arg.http_options.extra_body["service_tier"] == "standard"
assert provider._extra_body["http_options"]["extra_body"]["service_tier"] == "standard"
# ─── LiteLLM ──────────────────────────────────────────────────────────────────
+22 -8
View File
@@ -423,6 +423,28 @@ def test_global_mcp_enabled_tools_intersects_with_single_bank_mode(mock_memory):
assert "list_banks" not in tools # single-bank mode excludes it regardless
def test_mcp_instructions_append_to_retain_and_recall_descriptions(mock_memory):
"""HINDSIGHT_API_MCP_INSTRUCTIONS customizes retain/recall tool descriptions."""
from unittest.mock import MagicMock, patch
from hindsight_api.api.mcp import create_mcp_server
custom_instructions = "Also store every action you take."
mock_cfg = MagicMock()
mock_cfg.mcp_enabled_tools = ["retain", "recall", "reflect"]
mock_cfg.mcp_instructions = custom_instructions
with patch("hindsight_api.api.mcp._get_raw_config", return_value=mock_cfg):
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
tools = _tools(mcp_server)
expected_suffix = f"Additional instructions: {custom_instructions}"
assert expected_suffix in tools["retain"].description
assert expected_suffix in tools["recall"].description
assert expected_suffix not in tools["reflect"].description
@pytest.mark.asyncio
async def test_routing_logic_from_url_path():
"""Test that routing correctly selects server based on URL structure.
@@ -430,14 +452,6 @@ async def test_routing_logic_from_url_path():
Simulates the path parsing logic from MCPMiddleware.__call__ after the
prefix has been stripped. Any first path segment is treated as a bank_id.
"""
from hindsight_api.api.mcp import MCPMiddleware
# Mock memory
mock_memory = MagicMock()
# Create middleware
middleware = MCPMiddleware(None, mock_memory)
# Simulate different URL patterns and verify routing
# Path is what remains after stripping the /mcp prefix
test_cases = [
@@ -1911,3 +1911,101 @@ class TestBankToolFiltering:
# Filter bypassed — config resolver was never consulted, all tools visible
assert "recall" in visible
mock_memory_with_resolver._config_resolver.get_bank_config.assert_not_called()
@pytest.mark.asyncio
class TestToolAnnotations:
"""Every MCP tool must carry read-only / destructive hints (openWorldHint=False)."""
async def test_read_only_tool(self, mock_memory):
ann = _tools(_make_mcp_server(mock_memory, {"recall"}))["recall"].annotations
assert ann is not None
assert ann.readOnlyHint is True
assert ann.openWorldHint is False
async def test_reflect_is_read_only(self, mock_memory):
# reflect synthesizes an answer and persists nothing (memory_engine.reflect_async),
# so it carries readOnlyHint=True like recall.
ann = _tools(_make_mcp_server(mock_memory, {"reflect"}))["reflect"].annotations
assert ann is not None
assert ann.readOnlyHint is True
assert ann.openWorldHint is False
async def test_destructive_tool(self, mock_memory):
ann = _tools(_make_mcp_server(mock_memory, {"delete_bank"}))["delete_bank"].annotations
assert ann is not None
assert ann.readOnlyHint is False
assert ann.destructiveHint is True
async def test_write_tool_is_not_destructive(self, mock_memory):
ann = _tools(_make_mcp_server(mock_memory, {"retain"}))["retain"].annotations
assert ann is not None
assert ann.readOnlyHint is False
assert ann.destructiveHint is False
async def test_annotations_apply_in_single_bank_mode(self, mock_memory):
ann = _tools(_make_mcp_server(mock_memory, {"recall"}, include_bank_id=False))["recall"].annotations
assert ann is not None
assert ann.readOnlyHint is True
def _reflect_mcp_with_trace(include_bank_id_param: bool):
"""An MCP server whose reflect returns a result carrying tool_trace/llm_trace."""
from fastmcp import FastMCP
# Mirrors ReflectResult: the agentic loop's trace fields are large and present.
reflect_payload = {
"text": "answer",
"based_on": {"world": []},
"tool_trace": [{"tool": "recall", "output": "x" * 1000}],
"llm_trace": [{"model": "test", "output": "y" * 1000}],
}
memory = MagicMock()
memory.reflect_async = AsyncMock(
return_value=MagicMock(
model_dump_json=lambda indent=None: json.dumps(reflect_payload),
model_dump=lambda: dict(reflect_payload),
structured_output=None,
)
)
mcp = FastMCP("test")
config = MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
include_bank_id_param=include_bank_id_param,
tools={"reflect"},
)
register_mcp_tools(mcp, memory, config)
return mcp
def _reflect_result_data(result) -> dict:
"""The multi-bank reflect returns a JSON string; single-bank returns a dict."""
return json.loads(result) if isinstance(result, str) else result
@pytest.mark.asyncio
class TestReflectTraceOmission:
"""reflect must not leak the agentic tool_trace/llm_trace into MCP responses by default."""
@pytest.mark.parametrize("multi_bank", [True, False])
async def test_trace_omitted_by_default(self, multi_bank):
mcp = _reflect_mcp_with_trace(multi_bank)
data = _reflect_result_data(await _tools(mcp)["reflect"].fn(query="q"))
assert data["text"] == "answer"
assert "tool_trace" not in data
assert "llm_trace" not in data
@pytest.mark.parametrize("multi_bank", [True, False])
async def test_trace_included_when_requested(self, multi_bank):
mcp = _reflect_mcp_with_trace(multi_bank)
data = _reflect_result_data(await _tools(mcp)["reflect"].fn(query="q", include_trace=True))
assert "tool_trace" in data
assert "llm_trace" in data
@pytest.mark.parametrize("multi_bank", [True, False])
async def test_based_on_flag_is_independent_of_trace(self, multi_bank):
# include_based_on keeps based_on but must not pull the trace back in.
mcp = _reflect_mcp_with_trace(multi_bank)
data = _reflect_result_data(await _tools(mcp)["reflect"].fn(query="q", include_based_on=True))
assert "based_on" in data
assert "tool_trace" not in data
+30
View File
@@ -11,6 +11,7 @@ from hindsight_api.metrics import (
get_token_bucket,
create_metrics_collector,
initialize_metrics,
normalize_http_endpoint,
)
@@ -221,6 +222,17 @@ class TestMetricsCollector:
assert reflect_attrs["operation"] == "reflect"
assert reflect_attrs["source"] == "api"
def test_record_operation_result_records_with_explicit_success(self, collector):
"""Direct recording path used by the worker (source=worker, explicit success)."""
collector.record_operation_result("retain", bank_id="test_bank", success=False, duration=1.5, source="worker")
duration, attributes = collector.operation_duration.record.call_args[0]
assert duration == 1.5
assert attributes["operation"] == "retain"
assert attributes["source"] == "worker"
assert attributes["success"] == "false"
collector.operation_total.add.assert_called_once_with(1, attributes)
def test_record_operation_includes_bank_id_when_enabled(self):
"""Test that bank_id is included in attributes when metrics_include_bank_id is enabled."""
mock_config = MagicMock()
@@ -324,6 +336,24 @@ class TestGetTokenBucket:
assert get_token_bucket(1000000) == "50k+"
class TestNormalizeHttpEndpoint:
"""Tests for normalize_http_endpoint (low-cardinality HTTP metric labels)."""
def test_templates_high_cardinality_segments(self):
"""Bank ids (incl. non-numeric), UUIDs, and numeric ids collapse to placeholders."""
cases = [
("/v1/default/banks/user-1680/memories/recall", "/v1/default/banks/{bank_id}/memories/recall"),
("/v1/default/banks/tenant-acme/memories", "/v1/default/banks/{bank_id}/memories"),
("/v1/default/banks/user-1680", "/v1/default/banks/{bank_id}"),
("/v1/default/banks/3f8c1e2a-1111-2222-3333-444455556666/config", "/v1/default/banks/{bank_id}/config"),
("/v1/default/banks/42/config", "/v1/default/banks/{bank_id}/config"),
("/v1/default/banks", "/v1/default/banks"),
("/health", "/health"),
]
for raw, expected in cases:
assert normalize_http_endpoint(raw) == expected, raw
class TestLLMMetrics:
"""Tests for LLM-specific metrics recording."""
@@ -11,7 +11,7 @@ from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text, inspect
from sqlalchemy import create_engine, text
# ---------------------------------------------------------------------------
# Helpers
@@ -33,8 +33,17 @@ def _upgrade(db_url: str, revision: str) -> None:
command.upgrade(_alembic_cfg(db_url), revision)
def _downgrade(db_url: str, revision: str) -> None:
command.downgrade(_alembic_cfg(db_url), revision)
def _reset_public_schema(db_url: str) -> None:
engine = create_engine(db_url, isolation_level="AUTOCOMMIT")
try:
with engine.connect() as conn:
# This test rewinds/replays migration history against a persistent
# pg0 instance. Rebuild only its dedicated public schema so a
# previous run cannot leave alembic_version ahead of the real DDL.
conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
finally:
engine.dispose()
# ---------------------------------------------------------------------------
@@ -43,16 +52,16 @@ def _downgrade(db_url: str, revision: str) -> None:
@pytest.fixture(scope="module")
def pre_backsweep_db_url():
def pre_backsweep_db_url() -> str:
"""
Spin up a dedicated pg0 instance and ensure schema is at the revision
just before the backsweep so each test can seed orphan data and then
apply the backsweep itself.
Because pg0 data directories persist across test runs, the DB may
already be at head. We upgrade to head first (to ensure all tables
exist), then stamp the revision back to pre-backsweep so Alembic
treats the backsweep as not-yet-applied.
Because pg0 data directories persist across test runs, the DB may already
have schema from a previous test run. Reset this test's dedicated schema
first, then migrate to the real pre-backsweep revision instead of stamping
a head schema backward.
"""
from hindsight_api.pg0 import EmbeddedPostgres
@@ -63,10 +72,8 @@ def pre_backsweep_db_url():
finally:
loop.close()
# Ensure all tables exist (upgrade to head), then stamp back to
# pre-backsweep so the backsweep migration will actually run.
_upgrade(url, "heads")
command.stamp(_alembic_cfg(url), "f6g7h8i9j0k1")
_reset_public_schema(url)
_upgrade(url, "f6g7h8i9j0k1")
return url
@@ -75,7 +82,7 @@ def pre_backsweep_db_url():
# ---------------------------------------------------------------------------
def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url):
def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url: str) -> None:
"""
Seed four kinds of rows then apply the backsweep migration and verify:
@@ -114,15 +121,20 @@ def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url
conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": alive_bank})
# --- seed memory_units ---
def insert_mu(uid, bank, fact_type, sources=None):
def insert_mu(
uid: uuid.UUID,
bank: str,
fact_type: str,
sources: list[uuid.UUID] | None = None,
) -> None:
src_arr = "{" + ",".join(str(s) for s in (sources or [])) + "}"
conn.execute(
text(
"""
INSERT INTO memory_units
(id, bank_id, text, fact_type, source_memory_ids)
(id, bank_id, text, event_date, fact_type, source_memory_ids)
VALUES
(:id, :bank, :text, :ft, CAST(:src AS uuid[]))
(:id, :bank, :text, now(), :ft, CAST(:src AS uuid[]))
"""
),
{"id": uid, "bank": bank, "text": "test", "ft": fact_type, "src": src_arr},
@@ -150,7 +162,7 @@ def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url
# --- verify ---
with engine.connect() as conn:
def exists(uid):
def exists(uid: uuid.UUID) -> bool:
return conn.execute(text("SELECT 1 FROM memory_units WHERE id = :id"), {"id": uid}).fetchone() is not None
# Must be gone
@@ -0,0 +1,110 @@
"""Provider quota reset windows defer worker retries instead of failing retains."""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import APIStatusError
from hindsight_api.engine.llm_interface import ProviderRateLimitResetError
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
def _llm() -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider="zai",
model="glm-5-turbo",
api_key="test",
base_url="https://example.com/v1",
)
def _usage_limit_error(reset_at: str) -> APIStatusError:
body = {
"code": "1308",
"message": f"Usage limit reached for 5 hour. Your limit will reset at {reset_at}",
}
response = MagicMock()
response.status_code = 429
response.text = '{"code": "1308", "message": "usage limit"}'
response.headers = {}
return APIStatusError("rate limited", response=response, body=body)
def _short_retry_after_error() -> APIStatusError:
response = MagicMock()
response.status_code = 429
response.text = '{"code": "rate_limit", "message": "retry shortly"}'
response.headers = {"retry-after": "1"}
return APIStatusError("rate limited", response=response, body={"message": "retry shortly"})
@pytest.mark.asyncio
async def test_usage_limit_429_with_reset_defers_without_inner_retry() -> None:
llm = _llm()
reset_at = (datetime.now(UTC) + timedelta(hours=5)).replace(microsecond=0)
create = AsyncMock(side_effect=_usage_limit_error(reset_at.isoformat().replace("+00:00", "Z")))
llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
with patch(
"hindsight_api.engine.providers.openai_compatible_llm.asyncio.sleep",
new_callable=AsyncMock,
) as sleep:
with pytest.raises(ProviderRateLimitResetError) as exc_info:
await llm.call(
messages=[{"role": "user", "content": "x"}],
scope="retain_extract_facts",
max_retries=2,
)
assert create.await_count == 1
sleep.assert_not_awaited()
assert abs((exc_info.value.retry_at - reset_at).total_seconds()) < 1
assert "Provider quota exhausted" in str(exc_info.value)
@pytest.mark.asyncio
async def test_short_retry_after_429_uses_normal_retry_loop() -> None:
llm = _llm()
create = AsyncMock(side_effect=_short_retry_after_error())
llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
with patch(
"hindsight_api.engine.providers.openai_compatible_llm.asyncio.sleep",
new_callable=AsyncMock,
) as sleep:
with pytest.raises(APIStatusError):
await llm.call(
messages=[{"role": "user", "content": "x"}],
scope="retain_extract_facts",
max_retries=2,
max_backoff=60,
)
assert create.await_count == 3
assert sleep.await_count == 2
@pytest.mark.asyncio
async def test_extract_facts_from_text_preserves_provider_quota_reset(monkeypatch) -> None:
from hindsight_api.engine.retain import fact_extraction
retry_at = (datetime.now(UTC) + timedelta(hours=2)).replace(microsecond=0)
async def quota_limited_chunk(**_: object) -> None:
raise ProviderRateLimitResetError(retry_at=retry_at, message="quota resets later")
monkeypatch.setattr(fact_extraction, "_extract_facts_with_auto_split", quota_limited_chunk)
with pytest.raises(ProviderRateLimitResetError) as exc_info:
await fact_extraction.extract_facts_from_text(
text="Alice moved to Berlin.",
event_date=None,
llm_config=object(),
agent_name="TestAgent",
config=SimpleNamespace(retain_chunk_size=1000, retain_structured_chunk_size=None),
)
assert exc_info.value.retry_at == retry_at
assert "Fact extraction deferred by provider quota" in str(exc_info.value)
@@ -0,0 +1,78 @@
"""Tests for _strip_reasoning_tags helper in OpenAI-compatible LLM provider."""
from hindsight_api.engine.providers.openai_compatible_llm import _strip_reasoning_tags
class TestStripReasoningTags:
"""Test reasoning/thinking tag stripping from LLM responses."""
def test_plain_text_unchanged(self):
"""Text without reasoning tags passes through (modulo edge whitespace)."""
content = "User prefers functional programming patterns."
assert _strip_reasoning_tags(content) == content
def test_empty_string(self):
"""Empty string passes through."""
assert _strip_reasoning_tags("") == ""
def test_closed_think_stripped(self):
"""A closed <think>...</think> block is removed."""
content = "<think>let me reason</think>The answer is 42."
assert _strip_reasoning_tags(content) == "The answer is 42."
def test_closed_thinking_stripped(self):
assert _strip_reasoning_tags("<thinking>reasoning</thinking>Result") == "Result"
def test_closed_thought_stripped(self):
assert _strip_reasoning_tags("<thought>hmm</thought>Result") == "Result"
def test_closed_reasoning_stripped(self):
assert _strip_reasoning_tags("<reasoning>step by step</reasoning>Result") == "Result"
def test_startthink_endthink_stripped(self):
"""The |startthink|...|endthink| marker style is removed."""
content = "|startthink|internal monologue|endthink|Final output"
assert _strip_reasoning_tags(content) == "Final output"
def test_multiline_think_stripped(self):
"""DOTALL: a multi-line thinking block is fully removed."""
content = "<think>\nline one\nline two\n</think>\nThe real content."
assert _strip_reasoning_tags(content) == "The real content."
def test_unclosed_think_stripped_to_end(self):
"""An unclosed <think> (truncated output) is removed to end-of-string."""
content = "Partial answer.\n<think>I started thinking but got cut off"
assert _strip_reasoning_tags(content) == "Partial answer."
def test_unclosed_thinking_stripped_to_end(self):
content = "result text\n<thinking>dangling reasoning with no close"
assert _strip_reasoning_tags(content) == "result text"
def test_only_unclosed_think_becomes_empty(self):
"""Content that is entirely an unclosed thinking block collapses to empty."""
content = "<think>everything is reasoning and it never closed"
assert _strip_reasoning_tags(content) == ""
def test_multiple_blocks_stripped(self):
"""Multiple closed blocks are all removed."""
content = "<think>a</think>Hello <think>b</think>World"
assert _strip_reasoning_tags(content) == "Hello World"
def test_mental_model_markdown_contamination(self):
"""Real-world MiniMax-M3 free-form leak: <think> wrapping a markdown mental model."""
content = (
"<think>\n"
"The user keeps asking about FP. I should consolidate this.\n"
"</think>\n"
"# Mental Model: Coding Preferences\n\n"
"The user prefers functional programming patterns and immutable data."
)
result = _strip_reasoning_tags(content)
assert "<think>" not in result
assert "</think>" not in result
assert result.startswith("# Mental Model: Coding Preferences")
def test_unclosed_think_after_json_payload(self):
"""Truncated <think> trailing valid JSON is stripped (closing tag absent)."""
content = '{"facts": [{"what": "test"}]}\n<think>oops truncated'
assert _strip_reasoning_tags(content) == '{"facts": [{"what": "test"}]}'
@@ -0,0 +1,168 @@
"""Regression: sub-batch slices that each span MULTIPLE extraction chunks must
keep full chunk coverage on BOTH the sync (inline) and async (submitted) retain
paths.
Two distinct bugs hid behind the same symptom ingesting a large plain-text
document dropped most of its body (and any fact past the first slice). Both only
trigger when an oversized single item is split into sequential sub-batches whose
*slices each re-chunk into several extraction chunks* (the default config: batch
tokens 10k ~30k-char slices, re-chunked at 3k ~10 chunks/slice):
1. chunk_index offset (sync + async). retain_batch_async advanced the per-document
chunk_index cursor by re-chunking ``item["content"]`` AFTER the orchestrator
had consumed (popped) it ``chunk_text("")`` returns ``[""]`` (count 1), so
the cursor moved by 1 per sub-batch instead of by the real chunk count. Later
slices restarted ~1 slot in, colliding ``chunk_id = {bank}_{doc}_{index}`` and
overwriting earlier chunks via upsert.
2. whole-document recovery skip (async only). All sub-batches of one submitted
operation share one ``operation_id``; the first slice stamps the document into
``result_metadata.facts_committed_document_ids``. The crash-recovery fast-path
then saw every later slice's document already "committed" and skipped
extraction entirely, so only the first slice survived.
The existing #1888 coverage tests use ``RETAIN_BATCH_TOKENS=100`` (a ~300-char
budget, under the chunk size) so every slice collapses to ONE chunk which masks
both bugs (offset-by-1 happens to equal the real count, and a 1-chunk doc isn't
re-sliced). These tests size the body so each slice fans out to ~6 chunks, with
globally-unique tokens so no chunk-hash dedup hides a dropped slice, and assert
full coverage + contiguous indices + a needle planted in a late slice.
"""
from datetime import datetime, timezone
import pytest
from hindsight_api.config import clear_config_cache
# The async test submits via submit_async_retain, which inserts parent/child rows
# into async_operations. test_worker.py drives its own WorkerPoller.claim_batch()
# against the same pool, so running the two files on different xdist workers lets
# them steal each other's pending rows. Share the "worker_tests" group so they
# serialize on the same xdist process (matches test_async_batch_retain.py).
pytestmark = pytest.mark.xdist_group("worker_tests")
# Planted in a late paragraph so it lands in a late sub-batch slice — the first
# thing either bug drops (mirrors the field-reported "165 commits" fact that
# vanished on the async path). A single no-space token so it can't straddle a
# chunk boundary (a multi-word phrase can split across two chunks at this test's
# small 500-char chunk size and read as "dropped" when it wasn't).
NEEDLE = "NEEDLE_165_COMMITS_MERGED_INTO_THE_MAIN_BRANCH"
def _ts() -> float:
return datetime.now(timezone.utc).timestamp()
@pytest.fixture(autouse=True)
def _multichunk_split_env(monkeypatch):
# Small extraction chunks (500 chars) with a batch-token budget whose char
# budget (700 * 3 = 2100) spans several chunks, so each oversized sub-batch
# slice fans out to ~6 extraction chunks. Skip consolidation/observations to
# keep the test fast and deterministic.
monkeypatch.setenv("HINDSIGHT_API_RETAIN_CHUNK_SIZE", "500")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_BATCH_TOKENS", "700")
monkeypatch.setenv("HINDSIGHT_API_ENABLE_AUTO_CONSOLIDATION", "false")
monkeypatch.setenv("HINDSIGHT_API_ENABLE_OBSERVATIONS", "false")
clear_config_cache()
yield
clear_config_cache()
def _make_body(paragraphs: int = 24, needle_at: int = 20) -> str:
"""Plain-text transcript whose every token is unique across the whole body,
so no two extraction chunks can hash-collide (a real content-hash collision
would legitimately dedup and mask a dropped slice). The needle sits in a late
paragraph."""
lines = []
for i in range(paragraphs):
toks = " ".join(f"w{i:03d}t{j:03d}" for j in range(60))
if i == needle_at:
lines.append(f"[Turn {i}] Assistant: {NEEDLE} fact {toks}")
else:
lines.append(f"[Turn {i}] Assistant: progress {i}: {toks}")
return "\n\n".join(lines)
async def _chunk_coverage(memory, bank_id, document_id, request_context):
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
original_len = len(doc["original_text"])
chunks = await memory.list_document_chunks(bank_id, document_id, limit=10000, request_context=request_context)
items = chunks["items"]
sum_chunk_text = sum(len(c["chunk_text"]) for c in items)
indices = sorted(c["chunk_index"] for c in items)
needle_present = any(NEEDLE in c["chunk_text"] for c in items)
return original_len, sum_chunk_text, indices, needle_present
def _assert_full_coverage(label, original_len, sum_chunk_text, indices, needle_present):
# Sanity: the body must actually fan out to many chunks across several
# multi-chunk slices, or the test wouldn't exercise the bug at all.
assert len(indices) >= 16, f"{label}: only {len(indices)} chunks — body too small to exercise multi-chunk slices"
assert sum_chunk_text >= original_len * 0.9, (
f"{label}: chunks cover only {sum_chunk_text}/{original_len} chars "
f"(~{100 * sum_chunk_text // original_len}%) — a sub-batch slice was overwritten or skipped"
)
assert indices == list(range(len(indices))), (
f"{label}: chunk_index sequence is not contiguous: {indices} — sub-batch slices collided on chunk_id"
)
assert needle_present, f"{label}: the late-slice needle fact was dropped (offset collision or recovery skip)"
@pytest.mark.asyncio
async def test_sync_inline_multichunk_subbatch_coverage(memory, request_context):
"""Sync inline path (retain_batch_async): an oversized doc whose slices each
span several extraction chunks must keep full coverage (offset bug)."""
bank_id = f"test_multichunk_sync_{_ts()}"
document_id = "doc-multichunk-sync"
try:
body = _make_body()
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": body, "context": "big doc", "document_id": document_id}],
request_context=request_context,
)
cov = await _chunk_coverage(memory, bank_id, document_id, request_context)
_assert_full_coverage("sync", *cov)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.timeout(600)
async def test_async_submit_multichunk_subbatch_coverage(memory, request_context):
"""Async submit path (submit_async_retain → child op → worker): the same
oversized doc must keep full coverage too. Exercises both the offset bug and
the shared-operation_id whole-document recovery skip."""
import asyncio
bank_id = f"test_multichunk_async_{_ts()}"
document_id = "doc-multichunk-async"
try:
body = _make_body()
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=[{"content": body, "context": "big doc", "document_id": document_id}],
request_context=request_context,
)
operation_id = result["operation_id"]
# SyncTaskBackend (test backend) drains children inline; wait for the
# parent to reach a terminal state before reading chunks.
status = None
for _ in range(600):
status = await memory.get_operation_status(
bank_id=bank_id, operation_id=operation_id, request_context=request_context
)
if status["status"] in ("completed", "failed"):
break
await asyncio.sleep(0.1)
assert status is not None and status["status"] == "completed", (
f"async retain did not complete: {status['status'] if status else 'no status'}"
)
cov = await _chunk_coverage(memory, bank_id, document_id, request_context)
_assert_full_coverage("async", *cov)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+141 -10
View File
@@ -13,6 +13,7 @@ Tests cover:
import asyncio
import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
@@ -70,6 +71,99 @@ async def clean_operations(pool):
)
def test_metric_operation_label_normalises_retain_variants():
"""Worker completion metrics collapse retain variants onto operation="retain"
so they share the API path's series; other types pass through unchanged."""
from hindsight_api.worker.poller import _metric_operation_label
assert _metric_operation_label("retain") == "retain"
assert _metric_operation_label("batch_retain") == "retain"
assert _metric_operation_label("file_convert_retain") == "retain"
assert _metric_operation_label("consolidation") == "consolidation"
assert _metric_operation_label("reflect") == "reflect"
assert _metric_operation_label(None) == "unknown"
class TestWorkerOperationMetrics:
"""_execute_task_inner emits operation metrics on terminal outcomes only (no DB)."""
def _make_poller(self, executor):
from hindsight_api.worker import WorkerPoller
poller = WorkerPoller(backend=MagicMock(), worker_id="w-test", executor=executor)
# Stub terminal-state handlers so _execute_task_inner never touches the DB.
poller._mark_failed = AsyncMock()
poller._defer_operation = AsyncMock()
poller._schedule_retry = AsyncMock()
return poller
async def _run(self, executor, task_type="batch_retain"):
from hindsight_api.worker.poller import ClaimedTask
poller = self._make_poller(executor)
task = ClaimedTask(
operation_id=str(uuid.uuid4()),
task_dict={"type": task_type, "operation_type": task_type, "bank_id": "bank-1"},
schema=None,
)
collector = MagicMock()
with patch("hindsight_api.worker.poller.get_metrics_collector", return_value=collector):
await poller._execute_task_inner(task)
return collector
@pytest.mark.asyncio
async def test_executor_returning_normally_records_success(self):
"""Success is inferred from the executor returning without raising to the
poller. This deliberately includes deterministic failures that
memory_engine.execute_task handles itself and returns from normally
(file_convert_retain, non-retryable errors) at the poller boundary they
are indistinguishable from a clean completion, so they also record
success=true. The worker counter is therefore a completion-throughput
signal; authoritative failure visibility comes from the
hindsight_async_operations{status="failed"} gauge, which reads each
operation's final DB status.
"""
collector = await self._run(AsyncMock()) # executor returns normally
collector.record_operation_result.assert_called_once()
call = collector.record_operation_result.call_args
assert call.args[0] == "retain" # batch_retain normalised
assert call.kwargs["success"] is True
assert call.kwargs["source"] == "worker"
@pytest.mark.asyncio
async def test_failure_records_failure(self):
async def boom(_):
raise RuntimeError("kaboom")
collector = await self._run(boom)
collector.record_operation_result.assert_called_once()
assert collector.record_operation_result.call_args.kwargs["success"] is False
@pytest.mark.asyncio
async def test_deferral_not_counted(self):
from datetime import datetime, timezone
from hindsight_api.worker.exceptions import DeferOperation
async def defer(_):
raise DeferOperation(exec_date=datetime.now(timezone.utc), reason="later")
collector = await self._run(defer)
collector.record_operation_result.assert_not_called()
@pytest.mark.asyncio
async def test_retry_not_counted(self):
from datetime import datetime, timezone
from hindsight_api.worker.exceptions import RetryTaskAt
async def retry(_):
raise RetryTaskAt(retry_at=datetime.now(timezone.utc), message="transient")
collector = await self._run(retry)
collector.record_operation_result.assert_not_called()
def test_all_operation_types_have_slot_reservation_config():
"""Every operation_type used in memory_engine must be listed in
WORKER_SLOT_RESERVATION_TYPES so it can be reserved via env var.
@@ -832,6 +926,34 @@ class TestWorkerPoller:
# Defensive: confirm it wasn't a RetryTaskAt masquerading as Defer.
assert not isinstance(exc_info.value, RetryTaskAt)
@pytest.mark.asyncio
async def test_memory_engine_provider_quota_reset_becomes_defer_operation(self, memory, monkeypatch):
"""Provider quota windows should park worker tasks until the reset time."""
from datetime import UTC, datetime, timedelta
from hindsight_api.engine.llm_interface import ProviderRateLimitResetError
from hindsight_api.worker.exceptions import DeferOperation, RetryTaskAt
retry_at = (datetime.now(UTC) + timedelta(hours=5)).replace(microsecond=0)
async def quota_limited_retain(_task_dict: object) -> None:
raise ProviderRateLimitResetError(retry_at=retry_at, message="quota resets later")
monkeypatch.setattr(memory, "_handle_batch_retain", quota_limited_retain)
with pytest.raises(DeferOperation) as exc_info:
await memory.execute_task(
{
"type": "batch_retain",
"bank_id": "test-provider-quota-defer",
"contents": [{"content": "x"}],
}
)
assert exc_info.value.exec_date == retry_at
assert exc_info.value.reason == "quota resets later"
assert not isinstance(exc_info.value, RetryTaskAt)
@pytest.mark.asyncio
async def test_claim_batch_skips_consolidation_when_same_bank_processing(self, pool, backend, clean_operations):
"""Test that pending consolidation is skipped if same bank has one processing."""
@@ -947,9 +1069,11 @@ class TestWorkerPoller:
claimed = await poller.claim_batch()
# Should claim the retain task (non-consolidation tasks are unaffected)
assert len(claimed) == 1
assert claimed[0].operation_id == str(retain_op_id)
# Should claim the retain task (non-consolidation tasks are unaffected).
# Filter to our bank — parallel tests may contribute other claims.
my_claims = [c for c in claimed if c.task_dict.get("bank_id") == bank_id]
assert len(my_claims) == 1, f"Expected 1 claim for our bank, got {len(my_claims)}"
assert my_claims[0].operation_id == str(retain_op_id)
class TestWorkerRecovery:
@@ -1586,7 +1710,9 @@ class TestDynamicTenantDiscovery:
# First claim_batch should call list_tenants
claimed1 = await poller.claim_batch()
assert mock_extension.list_tenants_calls == 1
assert len(claimed1) == 2
# Filter to our bank — parallel tests may contribute other claims.
my_claims1 = [c for c in claimed1 if c.task_dict.get("bank_id") == bank_id]
assert len(my_claims1) == 2, f"Expected 2 claims for our bank, got {len(my_claims1)}"
# Add more tasks
for i in range(2):
@@ -1605,7 +1731,8 @@ class TestDynamicTenantDiscovery:
# Second claim_batch should call list_tenants again
claimed2 = await poller.claim_batch()
assert mock_extension.list_tenants_calls == 2
assert len(claimed2) == 2
my_claims2 = [c for c in claimed2 if c.task_dict.get("bank_id") == bank_id]
assert len(my_claims2) == 2, f"Expected 2 claims for our bank, got {len(my_claims2)}"
@pytest.mark.asyncio
async def test_poller_picks_up_new_tenants_without_restart(self, pool, backend, clean_operations):
@@ -1650,10 +1777,12 @@ class TestDynamicTenantDiscovery:
tenant_extension=dynamic_extension,
)
# First poll - only public schema
# First poll - only public schema. Filter to our bank — parallel tests
# may contribute other claims.
claimed1 = await poller.claim_batch()
assert len(claimed1) == 1
assert claimed1[0].schema is None # public is represented as None
my_claims1 = [c for c in claimed1 if c.task_dict.get("bank_id") == bank_id]
assert len(my_claims1) == 1, f"Expected 1 claim for our bank, got {len(my_claims1)}"
assert my_claims1[0].schema is None # public is represented as None
assert dynamic_extension.list_tenants_calls == 1
# Simulate tenant list changing (but we won't add a non-existent schema)
@@ -1675,12 +1804,14 @@ class TestDynamicTenantDiscovery:
# Second poll - list_tenants should be called again
claimed2 = await poller.claim_batch()
assert len(claimed2) == 1
my_claims2 = [c for c in claimed2 if c.task_dict.get("bank_id") == bank_id]
assert len(my_claims2) == 1, f"Expected 1 claim for our bank, got {len(my_claims2)}"
assert dynamic_extension.list_tenants_calls == 2 # Called again on second poll
# Third poll with no tasks - still calls list_tenants
claimed3 = await poller.claim_batch()
assert len(claimed3) == 0
my_claims3 = [c for c in claimed3 if c.task_dict.get("bank_id") == bank_id]
assert len(my_claims3) == 0, f"Expected 0 claims for our bank, got {len(my_claims3)}"
assert dynamic_extension.list_tenants_calls == 3 # Called again even with no tasks
@pytest.mark.asyncio
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.8.2"
version = "0.8.3"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.2",
"hindsight-api-slim[all]==0.8.3",
]
[tool.uv.sources]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.8.2"
version = "0.8.3"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+2 -2
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.8.2
version: 0.8.3
servers:
- url: /
paths:
@@ -3541,7 +3541,7 @@ paths:
This endpoint handles file upload, conversion, and memory creation in a single operation.
**Features:**
- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)
- Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription)
- Automatic file-to-markdown conversion using pluggable parsers
- Files stored in object storage (PostgreSQL by default, S3 for production)
- Each file becomes a separate document with optional metadata/tags
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -62,7 +62,7 @@ Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories
This endpoint handles file upload, conversion, and memory creation in a single operation.
**Features:**
- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)
- Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription)
- Automatic file-to-markdown conversion using pluggable parsers
- Files stored in object storage (PostgreSQL by default, S3 for production)
- Each file becomes a separate document with optional metadata/tags
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
)
// APIClient manages communication with the Hindsight HTTP API API v0.8.2
// APIClient manages communication with the Hindsight HTTP API API v0.8.3
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.2
API version: 0.8.3
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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