Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
64 changes: 53 additions & 11 deletions docs/modules/ROOT/pages/how-to/adopt-existing-graph.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
include::partial$backend-bolt-only.adoc[]

How to layer `neo4j-agent-memory` on top of a Neo4j graph that already
exists in production, so that library writes (entity extraction, MENTIONS
edges, relation writes) link to your existing nodes instead of creating
exists in production, so that library writes (MENTIONS edges, relation
writes, entity upserts) link to your existing nodes instead of creating
duplicates.

[.lead]
Expand Down Expand Up @@ -122,28 +122,70 @@ already-adopted count for the rest.
== Verification

After adoption, library writes that MERGE on `(:Entity {name, type})`
should link to your existing nodes:
land on your existing nodes. Name the entities explicitly so the write is
deterministic — automatic NER extraction does not link to adopted nodes
(see <<Limitations>>):

[source,python]
----
# Add a message that names someone in the existing graph.
from neo4j_agent_memory.schema.models import EntityRef

# Add a message that names people and movies in the existing graph.
await client.short_term.add_message(
"demo", "user", "Have you seen Inception? Bob Singh directed it."
"demo",
"user",
"Have you seen Inception? Bob Singh directed it.",
extraction_mode="explicit",
explicit_mentions=[
EntityRef(name="Inception", type="MOVIE"),
EntityRef(name="Bob Singh", type="PERSON"),
],
)

# Verify there's still exactly one node per name in the graph.
rows = await client.graph.execute_read(
# Count every node the library could have created, across *all* labels —
# a duplicate shows up as a second label set such as ["Entity", "Object"].
# `client.query.cypher` is the portable read accessor (it works on NAMS
# too); `client.graph` remains for write Cypher on bolt.
rows = await client.query.cypher(
"""
UNWIND ['Bob Singh', 'Inception'] AS target
MATCH (n {name: target})
WHERE n:Person OR n:Movie
RETURN target, count(n) AS count
MATCH (n) WHERE n.name = target
RETURN target, count(n) AS total, collect(DISTINCT labels(n)) AS label_sets
ORDER BY target
"""
)
for row in rows:
print(f"{row['target']}: {row['count']} (expect 1)")
print(f"{row['target']}: {row['total']} (expect 1) {row['label_sets']}")

# Adoption writes no embedding, so backfill before semantic search.
await client.long_term.add_entity(
"Inception", "MOVIE", resolve=False, deduplicate=False
)
movies = await client.long_term.search_entities(
"science fiction film", entity_types=["MOVIE"]
)
----

[[Limitations]]
== Limitations

Verified against v0.5.0 on bolt:

* **Automatic extraction does not link to adopted nodes.** The NER
extractors map their labels through POLE+O, so a `MOVIE` mention arrives
typed `OBJECT` and MERGEs a second `:Entity:Object` node; give the
extractor a `label_mapping` that preserves `MOVIE` and the MERGE finds the
adopted node but no `MENTIONS` edge is written, because the link step looks
the entity up by the id it generated rather than the id the MERGE returned.
Use `extraction_mode="explicit"` with `EntityRef` instead.
* **`SchemaConfig.strict_types` is not forwarded.** `MemoryClient` does not
pass `schema_config` to `LongTermMemory`, so out-of-schema types are not
rejected today.
* **Adopted ids must be UUID-shaped for the read helpers.** Nodes adopted
without a pre-existing `id` get a deterministic `<label_lc>:<name>` id;
helpers that hydrate `Entity.id` as a `UUID` (such as `search_entities()`)
raise `ValueError` on those rows. Read them with `client.query.cypher`.

== Edge cases

[cols="1,3"]
Expand Down
53 changes: 45 additions & 8 deletions docs/modules/ROOT/pages/how-to/audit-reasoning.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
:page-product: agent-memory
:toclevels: 2

include::partial$backend-bolt-only.adoc[]

NOTE: Writing `:TOUCHED` edges is bolt-only — NAMS drops
`touched_entities` today. The audit *read* is portable: run it through
`client.query.cypher`, which works on both backends.

How to make every agent reasoning step queryable from any entity it
referenced — a 1-hop traversal instead of the default 3-hop path.

Expand All @@ -23,9 +29,15 @@ Run the headline audit query in 1 hop:
----
MATCH (c:Entity {name: 'Anthem'})<-[:TOUCHED]-(s:ReasoningStep)
<-[:HAS_STEP]-(rt:ReasoningTrace)
RETURN rt.task, s.thought, rt.outcome
OPTIONAL MATCH (rt)-[:INITIATED_BY]->(m:Message)
RETURN rt.task, s.thought, rt.outcome, rt.success, rt.error_kind,
rt.metrics_json, m.content AS triggered_by
----

`TraceOutcome` lands as queryable columns — `rt.outcome` is the summary
string, with `success`, `error_kind` and `metrics_json` beside it — not an
opaque blob.

== Approach 1 — Pass `touched_entities` directly

Simplest: when you know the touched entities at call time, pass them
Expand All @@ -41,7 +53,7 @@ await client.reasoning.record_tool_call(
arguments={"client_name": "Anthem"},
result=[{"consultant": "Sara"}],
touched_entities=[
EntityRef(name="Anthem", type="Client"),
EntityRef(name="Anthem", type="CLIENT"),
EntityRef(name="Sara", type="PERSON"),
],
)
Expand All @@ -53,6 +65,11 @@ and writes a `pass:[(:ReasoningStep)-[:TOUCHED {recorded_at}]->(:Entity)]`
edge. Re-recording the same touched entity is a no-op — the relationship
is keyed on the (step, entity) pair.

IMPORTANT: Entity types are uppercase strings. The MERGE stores `type`
verbatim, so `type="Client"` creates a second `:Entity` node that
`add_entity` (which uppercases) can never match. Prefer
`EntityRef(id=...)` when the entity already exists.

== Approach 2 — Register an observer hook

Often the touched entities aren't known until the tool result is in
Expand All @@ -62,16 +79,26 @@ the result:

[source,python]
----
from typing import Any

from neo4j_agent_memory.schema.models import EntityRef


def infer_touched(tool_name, arguments, result):
def infer_touched(
tool_name: str,
arguments: dict[str, Any],
result: Any,
) -> list[EntityRef]:
"""Domain-specific mapping from tool calls to EntityRef lists."""
refs = []
refs: list[EntityRef] = []
if tool_name == "recommend_team":
refs.append(EntityRef(name=arguments["client_name"], type="Client"))
for row in result or []:
refs.append(EntityRef(name=row["consultant"], type="PERSON"))
client_name = arguments.get("client_name")
if client_name:
refs.append(EntityRef(name=client_name, type="CLIENT"))
if isinstance(result, list):
for row in result:
if isinstance(row, dict) and "consultant" in row:
refs.append(EntityRef(name=row["consultant"], type="PERSON"))
return refs


Expand All @@ -81,6 +108,16 @@ async def link_touched_entities(tool_call, ctx):
await ctx.add_touched_edge(ref)
----

The hook receives a `ToolCall` and a `HookContext` (both in
`neo4j_agent_memory.memory.reasoning`); annotate them when you type-check
your agent — `examples/audit-trail/main.py` shows the annotated form.

TIP: `client.reasoning` is typed as the portable `ReasoningProtocol`,
which does not carry the bolt-only hook. Construct the client with
`BoltSettings` and `await connect(settings)` to get a
`BoltMemoryClient`, whose `reasoning` is the concrete `ReasoningMemory` —
that is what makes the decorator and `get_tool_stats()` type-check.

Hook errors are logged but never raised — memory writes must not break
agent execution loops. Hooks fire in registration order, after the tool
call is persisted and after any `touched_entities` passed to
Expand All @@ -104,7 +141,7 @@ await client.reasoning.complete_trace(
summary="Recommendation failed: no consultants matched skills",
error_kind="no_results",
related_entities=[
EntityRef(name="Anthem", type="Client"),
EntityRef(name="Anthem", type="CLIENT"),
],
metrics={"tools_called": 1.0},
),
Expand Down
42 changes: 30 additions & 12 deletions docs/modules/ROOT/pages/how-to/buffered-writes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,27 @@ The agent responds before persistence completes:

[source,python]
----
async def agent_turn(client, content):
# Submit the write to the buffer; return immediately.
async def agent_turn(client, session_id, content):
# Value-returning APIs commit inline — the caller reads the result back,
# so they cannot be deferred.
message = await client.short_term.add_message(
session_id, "user", content, extraction_mode="skip",
)
# Derived writes that nothing in this turn reads back go on the buffer
# and return immediately.
await client.buffered.submit(
"MERGE (m:Message {id: $id}) SET m.content = $content",
{"id": message_id, "content": content},
"""
MATCH (c:Conversation {session_id: $session})
SET c.turns_recorded = coalesce(c.turns_recorded, 0) + 1
""",
{"session": session_id},
)
# The agent's response is not blocked on Neo4j.
return generate_response(content)
# The agent's response is not blocked on the derived write.
return generate_response(message.content)


async with MemoryClient(settings) as client:
response = await agent_turn(client, "Hello")
response = await agent_turn(client, "session-1", "Hello")
# Drain the queue at end of session / before shutdown.
await client.flush()
----
Expand Down Expand Up @@ -97,10 +106,15 @@ the agent's hot path:

[source,python]
----
errors = client.write_errors
if errors:
for err in errors:
log.warning("Buffered write failed: %s — %s", err.query[:32], err.error)
for err in client.write_errors:
# BufferedWriteError carries the originating Cypher, its parameters, the
# exception and when it failed.
log.warning(
"Buffered write failed at %s: %s — %s",
err.when.isoformat(),
err.query.strip()[:40],
type(err.error).__name__,
)
----

== Tradeoffs
Expand All @@ -125,4 +139,8 @@ if errors:
== See Also

* xref:how-to/multi-tenancy.adoc[Multi-Tenant Memory]
* `examples/buffered-writes/` — runnable example with a 50-turn timing comparison.
* link:https://github.com/neo4j-labs/agent-memory/tree/main/examples/buffered-writes[`examples/buffered-writes/`] — a runnable
example that times a 50-turn conversation in `sync` mode against the same
conversation in `buffered` mode, then drops `max_pending` to 4 so the bounded
queue's back-pressure is visible in the output. Runs with `llm=None` and a
local embedder; no API key.
Loading
Loading