Skip to content

Agents Engine

Verified against the code · 19d408be · 2026-08-25

Every canonical operation, signature, agent-class step sequence, status/HTTP-code table, and layer claim in the doc matches backend/agents/{init,base,create_node_agent,create_relation_agent,update_node_agent}.py and the POST /command handler in backend/server.py (lines 3675-3723) exactly. AgentResult fields, check_idempotency/command_hash behaviour, per-agent execute() flows (idempotency check -> validate -> derive uuid/find subtree root -> lock -> write overlay + idempotency record -> invalidate -> audit -> return), the 200/201/422/409/400 status table, and the mutation_engine bridge functions (validate_add_node, validate_create_relation, validate_update_node, derive_entity_uuid, find_subtree_root, build_overlay_node, build_overlay_relation, build_overlay_update_node) all check out against the code on disk.

Layer: api

Overview — AI-Driven Graph Mutations

The backend/agents/ module is the command dispatch layer for all graph mutations that arrive through POST /command. It is the single enforced path for creating nodes, creating relations, and updating nodes — no code in the system writes these overlay documents directly.

The term "agent" here does not refer to an LLM-based autonomous agent. It refers to the structural pattern of a dedicated, self-contained handler class that owns one command's entire lifecycle: idempotency check → validation → lock acquisition → overlay document write → audit record. This isolates the logic for each command type and prevents the route handler in server.py from accumulating mutation details.

An agent differs from a direct API call in three material ways:

  1. Uniform idempotency. Every agent begins by checking whether the incoming idempotencyKey has already been committed. If so, it returns the cached result without touching the graph. This is enforced at the base layer and cannot be bypassed by individual agent implementations.
  2. Subtree locking. Agents acquire a subtree-scoped async lock before writing. Direct writes would race against concurrent commands on the same subtree.
  3. Audit trail. Every execution — whether committed or rejected — appends a structured audit record. Agents call audit_service.make_rejected or audit_service.make_committed; the route handler is unaware of audit details.

Base Agent

backend/agents/base.py provides two shared primitives used by every agent class. There is no abstract base class with enforced subclassing; the shared contract is implemented through a consistent execute classmethod signature and direct use of these utilities.

AgentResult

@dataclass
class AgentResult:
    status: str           # "committed" | "rejected" | "conflict"
    entity_id: str = ""
    snapshot_id: str = ""
    errors: list = field(default_factory=list)
    warnings: list = field(default_factory=list)
    http_status: int = 201

AgentResult is the single return type for every agent execution. The route handler in server.py calls result.to_dict() to build the HTTP response body and uses result.http_status as the status code. No agent returns raw dicts or raises HTTP exceptions directly — all outcomes are expressed through AgentResult.

to_dict() produces:

{
  "result": "committed",
  "entityId": "<uuid>",
  "snapshotId": "<uuid>",
  "warnings": []
}

errors and warnings keys are omitted from the output when the lists are empty.

command_hash

def command_hash(payload: dict) -> str

Computes a SHA-256 digest of the canonical JSON serialization of payload (keys sorted, UTF-8 encoded). This hash is stored in the idempotency record alongside the result so that a replayed key carrying a different payload can be detected as a conflict.

check_idempotency

def check_idempotency(idem_key: str, payload: dict, store) -> AgentResult | None
Parameter Type Meaning
idem_key str The client-supplied idempotency key. Empty string is treated as absent — the check is skipped.
payload dict The raw request payload, used to compute the hash for conflict detection.
store module mutation_store, which exposes get_idempotency_record(key).

Returns: - None — key has not been seen before; the caller should proceed with execution. - AgentResult(status="conflict", http_status=409) — key was seen before with a different payload hash. The client reused a key for a different request. - AgentResult(status=<prior result>, http_status=200) — key was seen before with the same payload; the cached outcome is returned unchanged.

Every agent calls check_idempotency as its first action and returns immediately if the result is not None.


Agent Types

CreateNodeAgent

backend/agents/create_node_agent.py

Handles the CreateNode command. Translates the HTTP payload into the AddNode format expected by mutation_engine, runs validation, and writes an overlay node document.

Payload fields:

Field Type Required Meaning
label string yes Human-readable name for the new node
parentId UUID string yes Parent node under which the new node is placed
nodeType string no Ontological type of the node
properties dict no Arbitrary key-value properties to attach
description string no Free-text description

_to_engine_cmd(payload, idem_key) -> dict

Translates the public HTTP payload shape into the internal AddNode command dict accepted by mutation_engine. If the caller did not supply an idempotencyKey, a new UUID v4 is generated so that the mutation engine always receives a non-empty key.

execute(payload, idem_key, username, ip, subtree_lock_fn) -> AgentResult

Step-by-step execution:

  1. check_idempotency — return cached result if the key is known.
  2. Translate payload via _to_engine_cmd; compute command_hash.
  3. Load EffectiveGraph from mutation_store.get_effective_graph() and ownership assignments from mutation_store.load_ownership().
  4. Call mutation_engine.validate_add_node(cmd, username, eff, assignments)ValidationResult.
  5. If vr.has_errors: append a rejected audit record and return AgentResult(status="rejected", http_status=422).
  6. Derive the node's deterministic UUID via mutation_engine.derive_entity_uuid(cmd, parent_context=parent_id).
  7. Find the subtree root via mutation_engine.find_subtree_root(parent_id, eff).
  8. Acquire the subtree lock via async with subtree_lock_fn(subtree_root).
  9. Inside the lock: compute and write a snapshot, build the overlay node document via mutation_engine.build_overlay_node(...), write it atomically to MUTATIONS_DIR/nodes/<uuid>.jsonld, write the idempotency record, and invalidate the effective graph cache.
  10. Append a committed audit record.
  11. Return AgentResult(status="committed", entity_id=node_uuid, snapshot_id=snap_id, http_status=201).

The origin="manual" parameter passed to build_overlay_node distinguishes this path from agent-driven updates (compare UpdateNodeAgent, which passes origin="agent").


CreateRelationAgent

backend/agents/create_relation_agent.py

Handles the CreateRelation command. Creates a directed, typed relation between two existing nodes.

Payload fields:

Field Type Required Meaning
sourceId UUID string yes UUID of the source node
targetId UUID string yes UUID of the target node
relationType string yes Type identifier for the relation (validated against the relation catalog)

_to_engine_cmd(payload) -> dict

Produces a CreateRelation command dict. Note that this agent does not generate a fallback idempotency key — if no idem_key is provided, idempotency recording is skipped (the if idem_key: guard in step 9 below).

execute(payload, idem_key, username, ip, subtree_lock_fn) -> AgentResult

Step-by-step execution:

  1. check_idempotency — return cached result if the key is known.
  2. Translate payload via _to_engine_cmd; compute command_hash.
  3. Load EffectiveGraph from mutation_store.get_effective_graph().
  4. Call mutation_engine.validate_create_relation(cmd, username, eff)ValidationResult.
  5. If vr.has_errors: append a rejected audit record and return AgentResult(status="rejected", http_status=422).
  6. Derive a deterministic UUID v5 for the relation via key_derivation.derive_overlay_relation_uuid(sourceId, targetId, relationType). This is a content-addressed key: the same source/target/type triple always yields the same UUID (CCR Rule 16).
  7. Find the subtree root via mutation_engine.find_subtree_root(source_id, eff).
  8. Acquire the subtree lock.
  9. Inside the lock: compute and write a snapshot, build the overlay relation document via mutation_engine.build_overlay_relation(...), write it atomically to MUTATIONS_DIR/relations/<uuid>.jsonld, conditionally write the idempotency record (only if idem_key was provided), and invalidate the effective graph cache.
  10. Append a committed audit record.
  11. Return AgentResult(status="committed", entity_id=rel_uuid, snapshot_id=snap_id, http_status=201).

UpdateNodeAgent

backend/agents/update_node_agent.py

Handles the UpdateNode command. Merges incoming properties (and optionally a new label) onto an existing node's overlay document. The node's parent is not mutable through this command — structural integrity is preserved by keeping parent assignment frozen (CCR Rule 3).

Payload fields:

Field Type Required Meaning
id UUID string yes UUID of the node to update
properties dict yes Properties to merge into the existing overlay
newLabel string no If present, replaces the node's current label

_to_engine_cmd(payload) -> dict

Produces an UpdateNode command dict. The newLabel key is included only if the payload contains it — its absence tells mutation_engine.build_overlay_update_node to leave the existing label unchanged.

execute(payload, idem_key, username, ip, subtree_lock_fn) -> AgentResult

Step-by-step execution:

  1. check_idempotency — return cached result if the key is known.
  2. Translate payload via _to_engine_cmd; compute command_hash.
  3. Load EffectiveGraph and ownership assignments.
  4. Call mutation_engine.validate_update_node(cmd, username, eff, assignments)ValidationResult. Ownership assignments are checked because only the assigned owner (or an admin) may update a node.
  5. If vr.has_errors: append a rejected audit record and return AgentResult(status="rejected", http_status=422).
  6. Find the subtree root via mutation_engine.find_subtree_root(node_id, eff).
  7. Acquire the subtree lock.
  8. Inside the lock: compute and write a snapshot, build the update overlay via mutation_engine.build_overlay_update_node(..., origin="agent"), write it atomically to MUTATIONS_DIR/nodes/<node_id>.jsonld (overwriting any prior overlay for this node, which is the CCR Rule 8 replace-entire-overlay semantics), conditionally write the idempotency record, and invalidate the cache.
  9. Append a committed audit record.
  10. Return AgentResult(status="committed", entity_id=node_id, snapshot_id=snap_id, http_status=200).

Note the HTTP status code: UpdateNodeAgent returns 200 on success, while CreateNodeAgent and CreateRelationAgent return 201.


The Agent → Mutation Bridge

Agents do not have their own validation logic. All validation is delegated to mutation_engine:

  • mutation_engine.validate_add_node(cmd, username, eff, assignments)
  • mutation_engine.validate_create_relation(cmd, username, eff)
  • mutation_engine.validate_update_node(cmd, username, eff, assignments)

Each returns a ValidationResult with has_errors: bool, errors: list, and warnings: list. Agents inspect has_errors and branch accordingly.

There is no separate "preview" step in the agent path. The preview/commit two-phase pattern documented in mutation-engine.explanation.md belongs to a different call path (the legacy /preview and /commit routes). The agent path is single-phase: validate → lock → write → audit in one request.

The EffectiveGraph is the agent's view of the current graph state. It is loaded fresh at the start of each execute call via mutation_store.get_effective_graph(), which merges the base (read-only) graph with all committed overlay documents. Agents do not hold a reference to the graph across requests. After writing, they call mutation_store.invalidate() so that the next request sees the updated overlay.


API Integration

All agent commands enter through a single HTTP endpoint registered directly on the FastAPI app in backend/server.py:

POST /command
Authorization: Bearer <token>   (principal resolved by the CurrentUser dependency, api/deps.py)
Content-Type: application/json

Request body:

{
  "command": "CreateNode | UpdateNode | CreateRelation",
  "payload": { ... },
  "idempotencyKey": "<optional-uuid>"
}

The command_gateway route handler:

  1. Calls _require_mutations() — raises if the mutations subsystem is disabled.
  2. Calls agents.dispatch(command) to look up the agent class. Returns 400 with a descriptive error if the command name is not registered.
  3. Calls agent_cls.execute(payload, idem_key, username, ip, subtree_lock_fn=_get_subtree_lock).
  4. Logs the outcome via structured logging (_slog).
  5. Returns JSONResponse(result.to_dict(), status_code=result.http_status).

Response body (success):

{
  "result": "committed",
  "entityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "snapshotId": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}

Response body (validation failure):

{
  "result": "rejected",
  "entityId": null,
  "snapshotId": null,
  "errors": [
    { "code": "MISSING_FIELD", "message": "label is required", "field": "label" }
  ]
}

Response body (idempotency conflict):

{
  "result": "conflict",
  "entityId": null,
  "snapshotId": null,
  "errors": [
    {
      "code": "IDEMPOTENCY_CONFLICT",
      "message": "Idempotency key reused with a different payload.",
      "field": "idempotencyKey"
    }
  ]
}

HTTP status codes:

Outcome Code
Node created 201
Relation created 201
Node updated 200
Idempotency replay (same payload) 200
Validation failure 422
Idempotency conflict (different payload) 409
Unknown command 400

Configuration

The agents module has no LLM dependency and requires no AI provider configuration. The name "agents" reflects the structural dispatch pattern, not AI involvement.

Configuration that does affect agent behavior:

  • Mutations subsystem toggle. _require_mutations() in server.py reads an environment variable or config flag. If mutations are disabled (e.g., read-only deployment mode), POST /command raises immediately before any agent is invoked.
  • Subtree lock implementation. _get_subtree_lock is resolved in server.py and passed into each agent as subtree_lock_fn. Its implementation (in-process async lock, distributed lock, etc.) is transparent to the agents.
  • Mutation store paths. mutation_store.MUTATIONS_DIR determines where overlay node and relation documents are written. This is configured at the persistence layer, not in the agents module.

Integration

EffectiveGraph as Input Context

Agents do not receive natural language input. Every execute call receives a structured payload dict that the HTTP client constructs. The EffectiveGraph is loaded internally by each agent as its authoritative view of current graph state. It provides:

  • The set of existing nodes and their properties (used to validate parentId, sourceId, targetId, and ownership).
  • The parent-child structure (used to find the subtree root for lock scoping).
  • The current overlay state for a node being updated (so build_overlay_update_node can carry forward existing fields).

The EffectiveGraph is a read-only snapshot from the agent's perspective. Agents never modify it directly — they write new overlay files and then call mutation_store.invalidate() to expire the cache.

IAM Governance

Authentication is enforced by the canonical CurrentUser dependency (api/deps.py) on POST /command — it accepts a Bearer user session or an internal X-Service-Token. An unauthenticated request never reaches the agent dispatch layer.

Authorization is enforced inside the mutation engine's validation functions:

  • validate_add_node and validate_update_node receive the assignments dict (loaded from mutation_store.load_ownership()). These functions check whether username has the right to write under the target parent or modify the target node.
  • validate_create_relation receives the EffectiveGraph and checks whether the source and target nodes exist and the relationType is valid per the relation catalog.

If the user does not have permission, vr.has_errors is True and the agent returns a rejected result with a 422 status before any write occurs. The rejection is recorded in the audit log with the username and IP address, providing a full trail of denied attempts.

The agent dispatch registry (_REGISTRY in backend/agents/__init__.py) is static. There is no runtime mechanism to register new commands or bypass the registry — adding a new command type requires a new agent class and an explicit entry in _REGISTRY.