Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
parameter was renamed `entity` → `entity_id` (all in-tree callers pass it
positionally); code using the `entity=` keyword must switch to `entity_id=`.

### Fixed

- **NAMS `add_entity` no longer raises `ValidationError` when the server
auto-merges the create onto an existing entity.** NAMS resolves-before-create:
a sufficiently similar name responds `{id, resolution: "merged", merged_into,
confidence}` with no `name`/`type`, which previously failed Pydantic `Entity`
parsing. The client now follows up with `GET /entities/{id}` and returns the
canonical merged-into entity (falling back to the request's name/type if that
read fails). Entity responses with explicit `null` fields (NAMS projects
unset node properties as JSON `null` — e.g. `confidence` on a manually
created entity) now fall back to model defaults instead of failing
validation.

> **Docs note:** when this ships, flip the "REST-only / no SDK method" notes in
> `reference/rest-api.adoc`, `reference/ontology-api.adoc`, and
> `reference/authentication.adoc`, and the Python↔TS parity note in
Expand Down
51 changes: 50 additions & 1 deletion src/neo4j_agent_memory/nams/long_term.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from typing import TYPE_CHECKING, Any
from uuid import UUID

from neo4j_agent_memory.core.exceptions import NotSupportedError
from neo4j_agent_memory.core.exceptions import MemoryError, NotSupportedError
from neo4j_agent_memory.memory.long_term import (
Entity,
Fact,
Expand Down Expand Up @@ -167,10 +167,16 @@ def _normalize_entity(payload: dict[str, Any] | None) -> dict[str, Any]:
doesn't provide — we default them so Pydantic parsing succeeds.
NAMS types come back lowercase; uppercase them so package-side
consumers see the same type values they sent.

Explicit ``null`` fields are dropped so model defaults apply — NAMS
projects unset node properties as JSON ``null`` (e.g. ``confidence``
on a manually-created entity), which would otherwise fail parsing
for non-optional fields like ``confidence: float``.
"""
from datetime import datetime, timezone

data = snakeize_keys(payload) if isinstance(payload, dict) else {}
data = {k: v for k, v in data.items() if v is not None}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runs on every entity parse, not just merges, and strips identity fields.

  • "id": null -> client-side UUID via MemoryEntry.id's default_factory=uuid4. search_entities can then return entities whose id does not exist server-side; a later get_entity_relationships(entity.id) 404s with no clue why.
  • "created_at": null -> silently now().

Restrict to fields with meaningful defaults (confidence, description, subtype, updated_at).

if "created_at" not in data:
data["created_at"] = datetime.now(timezone.utc).isoformat()
if "metadata" not in data:
Expand Down Expand Up @@ -215,6 +221,13 @@ async def add_entity(
``{name, type, description?}`` per spec. Bolt-only kwargs
(``subtype``, ``aliases``, ``attributes``, ``confidence``,
``deduplicate``, ``geocode``, ``enrich``, etc.) are silently dropped.

NAMS resolves-before-create: when the name is close enough to an
existing entity, the server merges onto it and responds
``{id, resolution: "merged", merged_into, confidence}`` — no
``name``/``type``. We follow up with ``GET /entities/{id}`` and
return the canonical merged-into entity (falling back to the
request's name/type if that read fails).
"""
et = entity_type or kwargs.get("type") or kwargs.get("label")
if et is None:
Expand All @@ -227,8 +240,44 @@ async def add_entity(
}
)
payload = await self._transport.request(_SPEC_ADD_ENTITY, json=body)
if isinstance(payload, dict) and payload.get("resolution") == "merged":
payload = await self._resolve_merged_entity(payload, name=name, nams_type=body["type"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolution / merged_into / merge confidence are discarded — caller asks for "Alice Smith", gets "Alice" under a different id, no signal.

_normalize_entity already defaults metadata to {}; {"resolution": "merged", "merged_into": ..., "merge_confidence": ...} there is non-breaking.

return payload_to_model(_normalize_entity(payload), Entity)

async def _resolve_merged_entity(
self,
payload: dict[str, Any],
*,
name: str,
nams_type: str,
) -> dict[str, Any]:
"""Turn a ``resolution: "merged"`` create response into entity fields.

The merged response carries only ``{id, merged_into, confidence}``,
so we fetch the canonical entity it merged into. If that read
fails (or comes back without a name), synthesize a minimal record
from the request so the caller still gets a parseable Entity.
"""
merged_id = payload.get("merged_into") or payload.get("mergedInto") or payload.get("id")
if merged_id:
try:
detail = await self._transport.request(
_SPEC_GET_ENTITY,
path_params={"entity_id": _to_str(merged_id)},
)
except MemoryError:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MemoryError is the root of the NAMS error tree, so this also swallows AuthenticationError (401/403), RateLimitError (429) and TransportError (5xx/network). Only 404 maps to a bare MemoryError (transport.py:358).

Effect: a rate-limited or auth-failed canonical read returns the synthesized entity with the requested name — no error, no log.

Needs a NotFoundError in core/exceptions.py, or except MemoryError as exc: + if type(exc) is not MemoryError: raise.

detail = None
if isinstance(detail, dict) and detail.get("name"):
return detail
return _drop_none(
{
"id": merged_id,
"name": name,
"type": nams_type,
"confidence": payload.get("confidence"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing the merge score as Entity.confidence:

  1. It is a name-similarity score, not extraction confidence. The canonical-fetch path returns the entity's own value, so the field means different things per path — a caller filtering confidence >= 0.95 drops a canonical entity whose stored value is 1.0.
  2. Entity.confidence is Field(ge=0.0, le=1.0); an out-of-range score makes this branch raise the ValidationError it exists to prevent.

Drop it (model default 1.0) or move to metadata.

}
)

async def add_preference(self, category: str, preference: str, **kwargs: Any) -> Preference:
raise NotSupportedError(
backend="nams",
Expand Down
10 changes: 5 additions & 5 deletions tests/integration/nams/test_framework_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ async def test_crewai_smoke(nams_client: MemoryClient, nams_session: str) -> Non
except (ImportError, AttributeError):
pytest.skip("crewai integration unavailable")

# CrewAI's adapter is crew-scoped (no session_id) and uses an internal
# crew id rather than a pre-created NAMS conversation, so we assert it
# constructs against a NAMS-backed client and round-trip the write path
# through the shared client.
_ = Neo4jCrewMemory(memory_client=nams_client)
# CrewAI's adapter is crew-scoped: crew_id (not session_id) names the
# conversation it writes under. We assert it constructs against a
# NAMS-backed client and round-trip the write path through the shared
# client.
_ = Neo4jCrewMemory(memory_client=nams_client, crew_id=f"{nams_session}-crew")
marker = _marker()

async def store() -> None:
Expand Down
98 changes: 98 additions & 0 deletions tests/unit/nams/test_long_term.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,104 @@ async def test_basic_returns_entity_only(self, long_term):
# Outbound type is mapped to NAMS' lowercase enum.
assert body == {"name": "Alice", "type": "person", "description": "Test entity"}

@respx.mock
async def test_merged_resolution_fetches_canonical_entity(self, long_term):
# NAMS resolves-before-create: a near-duplicate name merges onto the
# existing entity and the response carries no name/type — the client
# must follow up with GET /entities/{id} for the canonical record.
respx.post("https://memory.test/v1/entities").respond(
200,
json={
"id": SAMPLE_ENTITY["id"],
"resolution": "merged",
"merged_into": SAMPLE_ENTITY["id"],
"confidence": 0.93,
},
)
get_route = respx.get(f"https://memory.test/v1/entities/{SAMPLE_ENTITY['id']}").respond(
200, json={**SAMPLE_ENTITY, "relationships": []}
)
entity = await long_term.add_entity("Alice Smith", "PERSON")
assert get_route.called
assert isinstance(entity, Entity)
assert str(entity.id) == SAMPLE_ENTITY["id"]
# The canonical (merged-into) record wins over the requested name.
assert entity.name == "Alice"
assert entity.type == "PERSON"

@respx.mock
async def test_merged_resolution_tolerates_null_fields_on_canonical_entity(self, long_term):
# NAMS projects unset node properties as JSON null — a manually
# created entity has no confidence/sourceStage/updatedAt. Those
# nulls must fall back to model defaults, not fail float parsing.
respx.post("https://memory.test/v1/entities").respond(
200,
json={
"id": SAMPLE_ENTITY["id"],
"resolution": "merged",
"merged_into": SAMPLE_ENTITY["id"],
"confidence": 0.93,
},
)
respx.get(f"https://memory.test/v1/entities/{SAMPLE_ENTITY['id']}").respond(
200,
json={
"id": SAMPLE_ENTITY["id"],
"name": "Alice",
"type": "person",
"description": None,
"confidence": None,
"sourceStage": None,
"createdAt": "2026-05-17T12:00:00Z",
"updatedAt": None,
"relationships": [],
},
)
entity = await long_term.add_entity("Alice Smith", "PERSON")
assert isinstance(entity, Entity)
assert entity.name == "Alice"
assert entity.confidence == 1.0 # model default
assert entity.description is None

@respx.mock
async def test_merged_resolution_falls_back_to_request_fields(self, long_term):
# If the follow-up read fails, synthesize a parseable Entity from the
# request instead of raising a ValidationError.
respx.post("https://memory.test/v1/entities").respond(
200,
json={
"id": SAMPLE_ENTITY["id"],
"resolution": "merged",
"merged_into": SAMPLE_ENTITY["id"],
"confidence": 0.93,
},
)
respx.get(f"https://memory.test/v1/entities/{SAMPLE_ENTITY['id']}").respond(
404, json={"error": "entity not found"}
)
entity = await long_term.add_entity("Alice Smith", "PERSON")
assert isinstance(entity, Entity)
assert str(entity.id) == SAMPLE_ENTITY["id"]
assert entity.name == "Alice Smith"
assert entity.type == "PERSON"
assert entity.confidence == 0.93

@respx.mock
async def test_review_pending_resolution_parses(self, long_term):
# review_pending responses include full entity fields plus the
# resolution/duplicate_of extras — those must parse cleanly.
respx.post("https://memory.test/v1/entities").respond(
201,
json={
**SAMPLE_ENTITY,
"resolution": "review_pending",
"duplicate_of": "00000000-0000-0000-0000-000000000002",
},
)
entity = await long_term.add_entity("Alice", "PERSON")
assert isinstance(entity, Entity)
assert entity.name == "Alice"

@respx.mock
async def test_bolt_only_kwargs_dropped(self, long_term):
route = respx.post("https://memory.test/v1/entities").respond(201, json=SAMPLE_ENTITY)
Expand Down
15 changes: 15 additions & 0 deletions typescript/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ appear in minor versions with a callout in this file.

## [Unreleased]

### Fixed

- **`longTerm.addEntity` no longer returns a malformed Entity when the
hosted service auto-merges the create onto an existing entity.** NAMS
resolves-before-create: a sufficiently similar name responds
`{id, resolution: "merged", merged_into, confidence}` with no
`name`/`type`, which previously flowed into an Entity with `undefined`
name and type. The client now follows up with `GET /entities/{id}` and
returns the canonical merged-into entity (falling back to the request's
name/type if that read fails). Entity responses with explicit `null` fields
(NAMS projects unset node properties as JSON `null` — e.g. `confidence` on a
manually created entity) are now normalized to `undefined` so optional
Entity fields match their declared types at runtime. Mirrors the same fix
in the Python SDK.

## 0.4.0 — NAMS alignment

Adds workspace addressing, a first-class ontology surface
Expand Down
72 changes: 61 additions & 11 deletions typescript/src/long-term/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* entity feedback, history, merge-by-id, graph view, and provenance.
*/

import { ValidationError } from "../errors.js";
import { MemoryError, ValidationError } from "../errors.js";
import type { Transport } from "../transport/index.js";
import type {
AddRelationshipOptions,
Expand Down Expand Up @@ -54,6 +54,18 @@ interface WireEntityRelRef {
properties?: Record<string, unknown>;
}

/**
* Hosted create response when NAMS resolves-before-create merges the new
* name onto an existing entity: `{id, resolution: "merged", merged_into,
* confidence}` — no name/type fields.
*/
interface WireMergedResolution {
id?: string;
resolution: string;
merged_into?: string;
confidence?: number;
}

interface WirePreference {
id: string;
category: string;
Expand Down Expand Up @@ -108,20 +120,23 @@ interface WireGraph {
edges?: WireGraphEdge[];
}

// NAMS projects unset node properties as JSON null (e.g. `confidence` on a
// manually-created entity) — normalize those to undefined so Entity's
// optional fields stay `T | undefined` at runtime, matching their types.
function toEntity(w: WireEntity): Entity {
return {
id: w.id,
name: w.name,
type: w.type,
subtype: w.subtype,
description: w.description,
embedding: w.embedding,
canonicalName: w.canonical_name,
subtype: w.subtype ?? undefined,
description: w.description ?? undefined,
embedding: w.embedding ?? undefined,
canonicalName: w.canonical_name ?? undefined,
createdAt: w.created_at ?? "",
updatedAt: w.updated_at,
confidence: w.confidence,
sourceStage: w.source_stage,
relationships: w.relationships?.map(toRelRef),
updatedAt: w.updated_at ?? undefined,
confidence: w.confidence ?? undefined,
sourceStage: w.source_stage ?? undefined,
relationships: w.relationships?.map(toRelRef) ?? undefined,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normalization stops at toEntitytoRelRef (and toPreference / toFact / toMention) still pass nulls through. The merged-entity GET returns inlined relationships, so nulls leak on exactly the path this PR adds: EntityRelationshipRef.targetName / properties can be null despite being typed | undefined.

};
}

Expand Down Expand Up @@ -192,13 +207,48 @@ export class LongTermMemory {
entityType: string,
options?: { description?: string },
): Promise<Entity> {
const wire = await this.transport.request<WireEntity>("add_entity", {
const wire = await this.transport.request<WireEntity | WireMergedResolution>("add_entity", {
name,
entity_type: entityType,
type: entityType,
description: options?.description,
});
return toEntity(wire);
// NAMS resolves-before-create: a sufficiently similar name merges onto an
// existing entity and the response carries no name/type — follow up with
// a GET for the canonical merged-into record.
if (wire && typeof wire === "object" && "resolution" in wire && wire.resolution === "merged") {
return this.resolveMergedEntity(wire, name, entityType);
}
return toEntity(wire as WireEntity);
}

/**
* Turn a `resolution: "merged"` create response into the canonical Entity.
*
* Fetches the merged-into entity; if that read fails (or no id was
* returned), synthesizes a minimal record from the request so the caller
* still gets a well-formed Entity.
*/
private async resolveMergedEntity(
wire: WireMergedResolution,
name: string,
entityType: string,
): Promise<Entity> {
const mergedId = wire.merged_into ?? wire.id;
if (mergedId) {
try {
return await this.getEntity(mergedId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Python's guard (long_term.py:270, if isinstance(detail, dict) and detail.get("name")): a 200 whose body lacks name returns {name: undefined} — the bug this PR fixes, one hop later.

Sharper: rest.ts:615 (204) and rest.ts:652 (empty body) return undefined, so getEntity -> toEntity(undefined) throws a raw TypeError reading .id. Not a MemoryError, so it escapes the catch below and propagates out of addEntity.

} catch (error) {
if (!(error instanceof MemoryError)) throw error;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same over-broad catch as Python: MemoryError is the base class, so AuthenticationError / ValidationError / TransportError all fall through to the synthesized entity.

Caveat on the obvious fix: NotFoundError (errors.ts:44) is exported but never thrown in src/rest.ts:622-648 throws TransportError with status for every non-ok response, 404 included. Narrowing to NotFoundError disables the fallback entirely. Use error instanceof TransportError && error.status === 404.

// Canonical record unreadable — fall through to the synthesized form.
}
}
return toEntity({
id: mergedId ?? "",
name,
type: entityType,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entityType verbatim diverges from every other path, which returns the server's lowercase echo: addEntity("x", "PERSON") gives type: "person" on create and on merged+GET, but "PERSON" here. Python passes the already-mapped body["type"] through the same normalization.

Also createdAt lands as "" (toEntity, line 135) -> new Date(entity.createdAt) is Invalid Date. Python synthesizes datetime.now(timezone.utc).

confidence: wire.confidence,
});
}

async addPreference(
Expand Down
Loading
Loading