Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4879235
fix(v1.6): harden backend and reliability boundaries
Coding-Dev-Tools Aug 17, 2026
5dbe2d4
fix(v1.6): close strict backend review gaps
Coding-Dev-Tools Aug 17, 2026
0940719
fix(v1.6): enforce exact retention and service modes
Coding-Dev-Tools Aug 17, 2026
1ae3bdf
fix(v1.6): expose exact backend configuration safely
Coding-Dev-Tools Aug 17, 2026
427c87b
fix(config): redact trusted file paths from parse errors
Coding-Dev-Tools Aug 17, 2026
db28ed0
test(config): add wildcard CORS origin and schemeless rejection tests
Coding-Dev-Tools Aug 17, 2026
5d3101c
fix(v1.6): address review feedback — lint, error redaction, connector…
Coding-Dev-Tools Aug 17, 2026
fe65f52
fix(v1.6): redact non-validation config and extractor close failures
Coding-Dev-Tools Aug 17, 2026
32c8cd7
fix(v1.6): relay URL case, MCP exact-mode eager init, graph filter race
Coding-Dev-Tools Aug 17, 2026
af583a8
fix(e2e): use Reload data click after clearing repo filter
Coding-Dev-Tools Aug 17, 2026
f031722
fix(v1.6): reject unknown selectors in exact mode across all factories
Coding-Dev-Tools Aug 17, 2026
e5b3378
fix(v1.6): shared exact-mode MCP startup, vector selector validation,…
Coding-Dev-Tools Aug 17, 2026
1b90ba4
fix(tests): use ModuleType for mcp_server mock in DNS rebinding test
Coding-Dev-Tools Aug 17, 2026
2994b23
fix(v1.6): connector ownership, resilient MCP imports
Coding-Dev-Tools Aug 17, 2026
94bc011
fix(v1.6): redact numeric config values and reject blank vector selec…
Coding-Dev-Tools Aug 17, 2026
e70e5f2
fix(hermes): guard provider init and config load against host crashes
Coding-Dev-Tools Aug 17, 2026
e9f9d29
fix(service): close owned encrypted connector on MemoryService shutdo…
Coding-Dev-Tools Aug 17, 2026
7c4a96a
fix(service): ensure owned connector closes even when engine shutdown…
Coding-Dev-Tools Aug 17, 2026
be42c98
fix(dashboard): redact arbitrary ValueError/RuntimeError in startup d…
Coding-Dev-Tools Aug 17, 2026
c7be333
fix(dashboard): remove substring heuristic from ValueError redaction
Coding-Dev-Tools Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ ENGRAPHIS_EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
# ENGRAPHIS_EMBED_REVISION=<lowercase 40-character model commit>
# Reject mutable remote embedding, reranker, and chunk-tokenizer tags before loading. Off by default.
# ENGRAPHIS_REQUIRE_IMMUTABLE_MODELS=0
# Fail startup when a configured optional backend cannot load instead of silently falling back.
# ENGRAPHIS_REQUIRE_EXACT_BACKENDS=0
# Embedding dimension is auto-detected from the model. Override only if needed.
# ENGRAPHIS_EMBED_DIM=384
# Vector index backend for server entrypoints: "auto" (default; use sqlite-vec when
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,6 @@ internal/

# Local curl/testing cookie jar — may contain live session cookies. Never commit.
cookies.txt

# uv lockfile (generated tooling, not a project dependency)
uv.lock
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,8 @@ file. It never searches the working directory for `.env`, and explicit process v
| `ENGRAPHIS_EMBED_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the embedding model. Loaded Hub commits or local artifact manifests identify persistent vector spaces; unresolved mutable identities keep vector recall fail-closed. |
| `ENGRAPHIS_RERANK_MODEL` | Not set | Optional sentence-transformers cross-encoder reranker |
| `ENGRAPHIS_RERANK_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the reranker |
| `ENGRAPHIS_REQUIRE_IMMUTABLE_MODELS` | `false` | When enabled, require a 40-hex commit before loading remote embedding models, rerankers, or chunk tokenizers; `local:` selectors and filesystem paths remain permitted |
| `ENGRAPHIS_REQUIRE_IMMUTABLE_MODELS` | `false` | When enabled, require a 40-hex commit before loading remote embedding models, rerankers, or chunk tokenizers; `local:` selectors and filesystem paths remain permitted |
| `ENGRAPHIS_REQUIRE_EXACT_BACKENDS` | `false` | When enabled, dashboard and standalone MCP startup fails if a configured optional backend is unavailable instead of silently falling back |
| `ENGRAPHIS_EXTRACTOR` | `none` | `none` = verbatim; `chunk` = offline structure-aware chunks; `llm` = free-form LLM facts; `llm_structured` = schema-validated facts + graph metadata |
| `ENGRAPHIS_CHUNK_TOKENIZER_MODEL` | Not set | Optional Hugging Face tokenizer used to enforce chunk budgets with the downstream reader's real tokenization; requires the optional `transformers` package |
| `ENGRAPHIS_CHUNK_TOKENIZER_REVISION` | Not set | Optional immutable tokenizer/model revision recorded in the chunk-counter identity; pin this for reproducible benchmark artifacts |
Expand Down
19 changes: 18 additions & 1 deletion engraphis/backends/embedder_st.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import logging
import os
import re
import threading
from numbers import Integral
from pathlib import Path
from typing import Any, Literal, Optional
Expand Down Expand Up @@ -228,6 +229,7 @@ def __init__(
"sentence-transformers model did not report a positive embedding dimension"
)
self._dim = int(dimension)
self._encode_lock = threading.Lock()

@property
def dim(self) -> int:
Expand All @@ -247,7 +249,12 @@ def embed(self, texts: list[str], *, kind: Literal["text", "code"] = "text") ->
if not texts:
return np.empty((0, self._dim), dtype=np.float32)
try:
vecs = self.model.encode(texts, normalize_embeddings=True, convert_to_numpy=True)
encode_lock = getattr(self, "_encode_lock", None)
if encode_lock is None:
encode_lock = threading.Lock()
self._encode_lock = encode_lock
with encode_lock:
vecs = self.model.encode(texts, normalize_embeddings=True, convert_to_numpy=True)
result = np.asarray(vecs, dtype=np.float32)
except (TypeError, ValueError, OverflowError, RuntimeError): # noqa: BLE001
raise RuntimeError("sentence-transformers returned malformed embeddings") from None
Expand All @@ -274,13 +281,18 @@ def get_embedder(
*,
revision: Optional[str] = None,
require_immutable_models: Optional[bool] = None,
require_exact: bool = False,
) -> Embedder:
"""Return a semantic model when available, else explicit lexical degradation.

Prefix a configured model with ``local:`` to require a local path or cached
model. That mode never asks sentence-transformers to download the model. It
is deliberately opt-in because a regular model identifier retains the existing
behavior for operators who want sentence-transformers to resolve it normally.

Args:
require_exact: When True, raise an error if the configured model is unavailable
instead of falling back to the deterministic embedder.
"""
global LAST_EMBEDDER_ERROR
if model_name:
Expand Down Expand Up @@ -315,6 +327,11 @@ def get_embedder(
# URLs, or filesystem paths. Keep only the exception class in diagnostics.
error_kind = type(exc).__name__
LAST_EMBEDDER_ERROR = error_kind
if require_exact:
raise RuntimeError(
f"Configured semantic embedder is unavailable ({error_kind}) "
f"and require_exact_backends=True prevents fallback to deterministic mode"
) from None
log = logging.getLogger("engraphis")
emit = log.info if isinstance(exc, ModuleNotFoundError) else log.warning
emit(
Expand Down
11 changes: 11 additions & 0 deletions engraphis/backends/encrypted_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ def __init__(self, driver, pragma: str) -> None:
self._driver = driver
self._pragma = pragma

def close(self) -> None:
"""Clear key material from memory. Best-effort: Python strings are immutable,
but removing the reference allows GC to reclaim the buffer sooner."""
self._pragma = ""
Comment thread
Coding-Dev-Tools marked this conversation as resolved.

def __del__(self) -> None:
try:
self.close()
except Exception: # noqa: BLE001
pass

def __call__(self, path: str):
if path != ":memory:":
Path(path).parent.mkdir(parents=True, exist_ok=True)
Expand Down
57 changes: 54 additions & 3 deletions engraphis/backends/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@ def get_extractor(
token_counter: Optional[Callable[[str], int]] = None,
token_counter_identity: Optional[str] = None,
require_immutable_models: Optional[bool] = None,
require_exact: bool = False,
) -> Extractor:
"""Factory mirroring ``get_embedder``/``get_vector_index``: config in, backend out.

Expand All @@ -820,6 +821,10 @@ def get_extractor(
settings. ``kind='llm_structured'`` returns a schema-validated extractor with
entity/relation extraction. Anything else — including an LLM kind with no usable
client — returns the offline passthrough.

Args:
require_exact: When True, raise an error if the configured LLM extractor cannot
be initialized instead of falling back to passthrough.
"""
kind = (kind or "none").lower()
if kind == "chunk":
Expand All @@ -843,19 +848,65 @@ def get_extractor(
token_counter_identity=token_counter_identity,
)
if kind == "llm_structured":
created_client = False
if llm is None:
try:
from engraphis.llm.client import LLMClient
llm = LLMClient()
except Exception:
created_client = True
except Exception as exc:
if require_exact:
raise RuntimeError(
f"Configured extractor 'llm_structured' requires LLM client but "
f"initialization failed ({type(exc).__name__}) and "
f"require_exact_backends=True prevents fallback to passthrough"
) from None
return PassthroughExtractor(fallback_from=kind)
if require_exact and created_client and not getattr(llm, "api_key", ""):
close = getattr(llm, "close", None)
if callable(close):
try:
close()
except Exception: # noqa: BLE001 - preserve the sanitized diagnostic
pass
raise RuntimeError(
"Configured extractor 'llm_structured' requires "
"ENGRAPHIS_LLM_API_KEY when require_exact_backends=True"
)
return StructuredLLMExtractor(llm)
if kind != "llm":
if kind not in ("none", "chunk", "llm", "llm_structured"):
if require_exact:
raise RuntimeError(
"Configured extractor selector is not recognized and "
"require_exact_backends=True prevents silent fallback to passthrough "
"(valid kinds: none, chunk, llm, llm_structured)"
)
return PassthroughExtractor()
if kind == "none":
return PassthroughExtractor()
created_client = False
if llm is None:
try:
from engraphis.llm.client import LLMClient
llm = LLMClient()
except Exception:
created_client = True
except Exception as exc:
if require_exact:
raise RuntimeError(
f"Configured extractor 'llm' requires LLM client but initialization "
f"failed ({type(exc).__name__}) and require_exact_backends=True "
f"prevents fallback to passthrough"
) from None
return PassthroughExtractor(fallback_from=kind)
if require_exact and created_client and not getattr(llm, "api_key", ""):
close = getattr(llm, "close", None)
if callable(close):
try:
close()
except Exception: # noqa: BLE001 - preserve the sanitized diagnostic
pass
raise RuntimeError(
"Configured extractor 'llm' requires ENGRAPHIS_LLM_API_KEY "
"when require_exact_backends=True"
)
return LLMExtractor(llm)
20 changes: 17 additions & 3 deletions engraphis/backends/graph_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,25 @@ def _items(self, key: str) -> list[Any]:
return []


def get_graph_extractor(kind: str = "none"):
def get_graph_extractor(kind: str = "none", *, require_exact: bool = False):
"""Factory mirroring ``get_extractor``: config in, backend out. ``kind='regex'``
-> heuristic NER; anything else (incl. ``'none'``) -> the no-op passthrough."""
if (kind or "none").lower() == "regex":
-> heuristic NER; ``kind='none'`` or empty -> the no-op passthrough.

Args:
require_exact: When True, raise on unknown kinds instead of silently
returning NullGraphExtractor.
"""
name = (kind or "none").lower().strip()
if name == "regex":
return RegexGraphExtractor()
if name == "none":
return NullGraphExtractor()
if require_exact:
raise RuntimeError(
"Configured graph extractor selector is not recognized and "
"require_exact_backends=True prevents silent fallback to NullGraphExtractor "
"(valid kinds: none, regex)"
)
return NullGraphExtractor()


Expand Down
25 changes: 22 additions & 3 deletions engraphis/backends/reranker.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import logging
import math
import threading
from typing import Any, Optional

from engraphis.backends.model_source import validate_model_source
Expand All @@ -29,7 +30,8 @@ class CrossEncoderReranker:

def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2", *,
revision: Optional[str] = None,
require_immutable_models: Optional[bool] = None) -> None:
require_immutable_models: Optional[bool] = None,
batch_size: int = 32) -> None:
validate_model_source(
model_name,
revision,
Expand All @@ -49,6 +51,8 @@ def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2", *,
if local_files_only:
kwargs["local_files_only"] = True
self.model = CrossEncoder(resolved_model_name, **kwargs)
self._batch_size = batch_size
self._predict_lock = threading.Lock()

def rerank(self, query: str, candidates: list[Candidate], k: int) -> list[Candidate]:
if not candidates:
Expand All @@ -58,7 +62,8 @@ def rerank(self, query: str, candidates: list[Candidate], k: int) -> list[Candid
for c in candidates
]
try:
scores = list(self.model.predict(pairs))
with self._predict_lock:
scores = list(self.model.predict(pairs, batch_size=self._batch_size))
except (TypeError, ValueError) as exc:
raise RuntimeError("cross-encoder returned malformed scores") from exc
if len(scores) != len(candidates):
Expand All @@ -79,8 +84,15 @@ def get_reranker(
*,
revision: Optional[str] = None,
require_immutable_models: Optional[bool] = None,
require_exact: bool = False,
batch_size: int = 32,
) -> Reranker:
"""Return a cross-encoder reranker if a model is given and loads, else identity."""
"""Return a cross-encoder reranker if a model is given and loads, else identity.

Args:
require_exact: When True, raise an error if the configured model is unavailable
instead of falling back to the identity reranker.
"""
if model_name:
# Policy errors stay outside the optional-loader fallback: strict mode must
# reject a mutable remote source rather than quietly disabling reranking.
Expand All @@ -95,10 +107,17 @@ def get_reranker(
model_name,
revision=revision,
require_immutable_models=require_immutable_models,
batch_size=batch_size,
)
except Exception as exc: # noqa: BLE001 - optional dependency fallback
# Third-party loader errors can include credentials, signed URLs, local
# paths, and model identifiers. Keep diagnostics actionable but redacted.
if require_exact:
raise RuntimeError(
f"Configured cross-encoder reranker is unavailable "
f"({type(exc).__name__}) and require_exact_backends=True prevents "
f"fallback to identity reranker"
) from None
logger.warning(
"Configured cross-encoder reranker unavailable (%s); using identity reranker",
type(exc).__name__,
Expand Down
32 changes: 31 additions & 1 deletion engraphis/backends/retention.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,41 @@ def decide(self, content: str, *, title: str = "", mtype: MemoryType,
)


def get_retention_supervisor(mode: str = "none") -> Optional[RetentionSupervisor]:
def get_retention_supervisor(
mode: str = "none", *, require_exact: bool = False,
) -> Optional[RetentionSupervisor]:
"""Return the configured supervisor, or ``None`` for deterministic-only writes."""
name = str(mode or "none").strip().lower()
if name in ("", "none", "off", "disabled"):
return None
if name == "llm":
if require_exact:
_missing_key_msg = "retention supervisor requires ENGRAPHIS_LLM_API_KEY"
try:
from engraphis.llm.client import LLMClient
client = LLMClient()
try:
if not client.api_key:
raise RuntimeError(_missing_key_msg)
finally:
client.close()
except RuntimeError as exc:
# Only our own missing-key message is value-free and safe to re-raise.
# Every other RuntimeError (provider setup, proxy credentials, TLS
# failures surfaced by the client constructor) must be redacted so
# operator logs cannot leak third-party detail.
if str(exc) == _missing_key_msg:
raise
raise RuntimeError(
"configured retention supervisor is unavailable "
f"({type(exc).__name__}) and require_exact_backends=True prevents "
"deferred fallback"
) from None
except Exception as exc: # noqa: BLE001 - redact provider setup failures
raise RuntimeError(
"configured retention supervisor is unavailable "
f"({type(exc).__name__}) and require_exact_backends=True prevents "
"deferred fallback"
) from None
return LLMRetentionSupervisor()
raise ValueError("retention supervisor must be 'none' or 'llm'")
Loading