`extract_attributes_from_nodes` assigns `node.attributes = attributes`
unconditionally, and `_extract_entity_attributes` returned a bare `{}` whenever
no entity type applied to the node. Because a deduplicated node is the node
hydrated from the database (`_promote_resolved_node` returns the existing
candidate, and `get_entity_node_from_record` populates `attributes`), and the
entity save queries replace attributes wholesale, that `{}` silently cleared
attributes a previous typed pass had stored.
Two paths reach it:
- `entity_types=None` on a later episode for a node whose attributes were
written by an earlier call that did pass `entity_types`.
- `entity_types` supplied but missing the node's label, since
`entity_types.get(...)` then resolves to None. A node whose only label is
`Entity` resolves the lookup key to `''` and takes the same path.
The typed path already preserves prior values via
`apply_capped_attributes(..., merge_mode='overlay')`; only the
no-applicable-type early return bypassed it. Returning the node's prior
attributes makes the documented contract at the call site ("returns the
already-merged attribute dict, so direct assignment is the merge") true on both
paths, so no call-site guard is needed.
This matches the invariant `add_triplet` already enforces ("Update attributes
dictionary (merge rather than replace)"), covered by
`test_add_triplet_empty_attributes_preserved`. That test depends on the
`graph_driver` fixture, which parametrizes to zero drivers when all backends are
disabled, so it does not run in the default unit-test CI job. The two tests added
here need no database and do run there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ax326xYkRzfFiZZu9n3Fjz
* fix edge cross-encoder shortlist via balanced merge (#1642)
Round-robin merge retrieval result sets before CE ranking so BM25 insertion order cannot crowd out cosine/BFS candidates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* use RRF with 2x limit for edge cross-encoder shortlist (#1642)
Replace balanced_merge with RRF fusion and a 2*limit CE candidate cap so multi-method hits reach ranking without unbounded cost.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: preserve relationship direction in get_by_node_uuid and BFS edge search
Switch get_entity_edge_return_query to use startNode(e)/endNode(e) for
NEO4J/FALKORDB/NEPTUNE so source/target reflect the stored relationship
direction regardless of MATCH directionality. Fixes direction reversal
in EntityEdge.get_by_node_uuid and edge_bfs_search when the queried
node is the relationship target.
KUZU is unchanged: its 'e' is a RelatesToNode_ intermediate node, not
a relationship, and its MATCH is already directed.
Adds unit tests for the return-query builder and a regression assertion
in test_entity_edge for the target-side get_by_node_uuid case.
Fixes#1662
* update BFS query shape expectation
---------
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
Co-authored-by: prestonrasmussen <prasmuss15@gmail.com>
* add Socket Firewall enforcement to CI
Route dependency installs and official image builds through Socket while preserving fork PR and public Docker fallbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* bypass build caches when Socket Firewall is enforced
sfw can only inspect packages that are actually downloaded, so a release
image could ship a package flagged after its layer was first built. Two
caches hid dependencies from enforced builds: the BuildKit layer cache
skipped the install step outright, and the uv cache mount let uv resolve
wheels from disk without contacting PyPI even when the step did run.
Enforced builds now set a per-run SOCKET_SCAN_ID to invalidate the install
layer and export UV_NO_CACHE=1. Community builds leave SOCKET_SCAN_ID empty
and keep both caches.
The connectivity smoke test compares a stamp written inside the install
layer rather than grepping the build log, since BuildKit echoes instruction
text for CACHED steps too, and asserts an unchanged scan id still hits the
cache so the check cannot pass vacuously.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Completes #1500 on the generic edge_bfs_search path in search_utils.py,
which is what executes today for FalkorDB and Neo4j: driver.search_interface
is never assigned, so the driver-level operations modules #1500 patches are
not reached at runtime.
Mirrors the exact transform #1500 applies to the FalkorDB driver copy of
this query: replace the per-row re-MATCH by uuid
(MATCH (n:Entity)-[e:RELATES_TO {uuid: rel.uuid}]-(m:Entity)) with a direct
WITH over the relationship already produced by UNWIND relationships(path),
deriving endpoints via startNode/endNode. The explicit
type(e) = 'RELATES_TO' guard is required because the BFS pattern traverses
RELATES_TO|MENTIONS and the old re-MATCH filtered MENTIONS out implicitly.
As a side effect this also removes the duplicated/swapped source-target rows
on this path (the old undirected re-MATCH matched each edge twice; see #789
and PR #1436): startNode/endNode yields one row per relationship in its
original orientation. #789 also flags get_embeddings_for_edges, which is out
of scope here.
Refs #1272, #1500. Related to #789, #1436.
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
EntityEdge.save() persists reference_time on every provider and the record
parsers read it from the top-level record, but get_entity_edge_return_query()
never aliased `e.reference_time AS reference_time`. The field was therefore
silently dropped on every read-back (get_by_uuid/get_by_uuids/get_between_nodes/
get_by_node_uuid/get_by_group_ids), so a saved reference_time always came back
None. Add the alias to both query branches, matching the existing
expired_at/valid_at/invalid_at aliases.
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
build_communities(group_ids=[...]) documents 'Create communities only for
the listed group_ids', but its clear step called remove_communities with no
group filter — deleting EVERY Community node in the database before
recreating only the selected groups. A scoped rebuild therefore silently
destroyed all other groups' communities.
Thread group_ids through remove_communities and all four driver
implementations (neo4j, falkordb, kuzu, neptune) plus the
GraphOperationsInterface signature: scoped DETACH DELETE with
WHERE c.group_id IN $group_ids when group_ids is provided, unchanged
full wipe when it is not (blank/None keeps the documented
whole-graph behavior).
Fixes#1657
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
* fix(mcp): configure cross-encoder from providers instead of hardcoded OpenAI
The MCP server never passed a cross_encoder to Graphiti(), so it fell back to OpenAIRerankerClient and required OPENAI_API_KEY even on fully non-OpenAI setups.
Add a CrossEncoderFactory that picks a reranker from the LLM provider, then the embedder provider, and falls back to the local BGE reranker, and wire it into both Graphiti() construction sites.
Fixes#1393
* fix(mcp): fail when local reranker is unavailable
---------
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
* fix: capture init task ref + cancel on close for Neo4jDriver and FalkorDriver
Both Neo4jDriver and FalkorDriver __init__ scheduled
build_indices_and_constraints() via loop.create_task() without
capturing the task reference. The orphan task could:
1. Race with explicit build_indices_and_constraints() calls
2. Produce 'Task exception was never retrieved' warnings on failure
3. Leave the connection in a mid-transaction state when the driver
is closed concurrently
Now captures the task in self._init_task and cancels it in close(),
with suppress(CancelledError) to handle the cancellation cleanly.
Closes#1513
* fix: retrieve exception from already-done init tasks in close()
Previously, close() only cancelled and awaited tasks that were
still running, but skipped already-done tasks. If a done task
failed with an exception, the exception was never retrieved,
causing 'Task exception was never retrieved' warnings.
Now calls task.exception() on done tasks to consume any pending
exception and complete the task's lifecycle.
* fix: guard exception() against cancelled init tasks in close()
Task.exception() raises CancelledError if the task finished in a
cancelled state. If the init task was cancelled externally before
close() ran, the else branch would re-raise CancelledError out of
close(), breaking driver cleanup.
Skip exception retrieval for cancelled tasks; only call exception()
on tasks that completed normally or failed.
---------
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
fix: route FalkorDB namespace/ops get_by_group_ids to correct graph
The classmethod get_by_group_ids paths were routed to the right FalkorDB
graph, but the namespace/ops read path (e.g. graphiti.nodes.episode.
get_by_group_ids) delegates to the Falkor *Operations classes with the base
driver (default_db) and never cloned to the group's graph, so a single
group_id read returned 0 rows.
Apply the same single-group clone + multi-group fan-out routing to all nine
FalkorDB operations get_by_group_ids methods (episode/entity/community/saga
nodes and episodic/entity/community/has_episode/next_episode edges), guarded
by isinstance(executor, GraphDriver) so the QueryExecutor param narrows to a
clone-capable driver. Adds a package-free routing regression test.
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
Remove claude.yml, claude-code-review.yml, and claude-code-review-manual.yml
so @claude invocations and automated/manual Claude PR reviews no longer run.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Chalef <danielchalef@users.noreply.github.com>
Clarify that Zep is powered by a proprietary graph database (the Context
Graph Engine) built for millions of context graphs with low-latency
retrieval, so production deployments don't require a third-party graph
database. Adds a paragraph to the Zep section and a "Graph database" row
to the Zep vs. Graphiti comparison table.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
* fix(falkordb): route single group_id search to the matching graph
handle_multiple_group_ids only cloned the driver when len(group_ids) > 1.
add_episode re-binds self.driver for writes, so a fresh process searching
one group_id hit the driver's default database and returned empty results.
Clone call-scoped for a single differing group_id as well; do not reassign
the shared driver.
Fixes#1659
* test: drop unused MagicMock import (ruff F401)
---------
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>
The FalkorDB fulltext query builder had two bugs that caused
RediSearch syntax errors during entity dedup-search:
1. Backtick characters were missing from the separator map, so they
leaked through to RediSearch and broke query parsing. This is
common when LLMs extract entity names from code-fenced text.
2. When stopword filtering removed all tokens (or input was purely
punctuation), the builder still constructed a query with empty
parentheses, which RediSearch rejects with a syntax error.
Fix applied to both copies of the query builder:
- graphiti_core/driver/falkordb/operations/search_ops.py
- graphiti_core/driver/falkordb_driver.py
Closes#1440
Co-authored-by: Preston Rasmussen <109292228+prasmussen15@users.noreply.github.com>