Audit Reasoning with :TOUCHED Edges

Available on NAMS: No. The APIs on this page are bolt-only — calling them against the hosted NAMS backend raises NotSupportedError. See the capability matrix for what NAMS provides instead.

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.

By default, the library writes (ReasoningTrace)-[:INITIATED_BY]→(Message)-[:MENTIONS]→(Entity) and (ToolCall)-[:TRIGGERED_BY]→(Message), so reaching an entity from a step is three hops. The library also provides a direct (:ReasoningStep)-[:TOUCHED]→(:Entity) edge that you can write either explicitly or via an observer hook. This page shows both.

Goal

Provenance chain: graph traversal and vector similarity both trace agent decisions back to source messages and entities

Run the headline audit query in 1 hop:

MATCH (c:Entity {name: 'Anthem'})<-[:TOUCHED]-(s:ReasoningStep)
      <-[:HAS_STEP]-(rt:ReasoningTrace)
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 into record_tool_call(…​).

from neo4j_agent_memory.schema.models import EntityRef

await client.reasoning.record_tool_call(
    step.id,
    tool_name="recommend_team",
    arguments={"client_name": "Anthem"},
    result=[{"consultant": "Sara"}],
    touched_entities=[
        EntityRef(name="Anthem", type="CLIENT"),
        EntityRef(name="Sara", type="PERSON"),
    ],
)

The library MERGEs each EntityRef into the long-term memory layer (matching by id if provided, else name + type, else name only) and writes a (: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.

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 hand (e.g. recommend_team returns a list of consultants). Register a hook that fires after every record_tool_call and adds edges based on the result:

from typing import Any

from neo4j_agent_memory.schema.models import EntityRef


def infer_touched(
    tool_name: str,
    arguments: dict[str, Any],
    result: Any,
) -> list[EntityRef]:
    """Domain-specific mapping from tool calls to EntityRef lists."""
    refs: list[EntityRef] = []
    if tool_name == "recommend_team":
        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


@client.reasoning.on_tool_call_recorded
async def link_touched_entities(tool_call, ctx):
    for ref in infer_touched(tool_call.tool_name, tool_call.arguments, tool_call.result):
        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.

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 record_tool_call have been written.

Indexable structured outcomes

Graph provenance: a single Cypher traversal from a credit decision through shared entities back to the original KYC findings

Pair :TOUCHED with TraceOutcome to make audit queries filterable by structured failure mode:

from neo4j_agent_memory.schema.models import TraceOutcome

await client.reasoning.complete_trace(
    trace.id,
    outcome=TraceOutcome(
        success=False,
        summary="Recommendation failed: no consultants matched skills",
        error_kind="no_results",
        related_entities=[
            EntityRef(name="Anthem", type="CLIENT"),
        ],
        metrics={"tools_called": 1.0},
    ),
)

error_kind is a top-level indexed property on :ReasoningTrace, so you can scan failure modes cheaply:

MATCH (rt:ReasoningTrace {error_kind: 'timeout'})
RETURN rt.task, rt.completed_at, rt.outcome
ORDER BY rt.completed_at DESC

The related_entities list is materialized as :TOUCHED edges on the most recent step of the trace, so trace-level audit queries continue to work via the same 1-hop path.

Step-level case-based retrieval

For case-based imitation prompting, search past steps by their thought/action — coarser-grained get_similar_traces searches the trace task, which is often too high-level:

results = await client.reasoning.search_steps(
    "query schema before joining",
    limit=10,
    success_only=True,
)
for r in results:
    print(f"({r.similarity:.2f}) parent={r.parent_task!r} step={r.step.thought!r}")

Each result includes the parent trace’s task and outcome so the LLM can see "in a similar situation, here’s what worked."

See Also

  • Record Reasoning Traces — the underlying trace + step + tool-call API.

  • examples/audit-trail/ — runnable end-to-end example using this how-to.