Behaviors¶
A behavior is the framework's unit of reactive code. It subscribes to events, runs when its subscription matches, and produces more events (new objects, new relations, patches, custom events). The runtime dispatches behaviors against events in the queue until the queue is empty.
Behaviors are how a developer adds custom logic to the framework. Most code that ships with a pack is behaviors. Most code a developer writes is behaviors.
A behavior is not an agent. It doesn't decide what to do — it reacts. The decision is the subscription rule; the work is the body. An agentic-feeling system emerges from many small behaviors firing in response to each other's outputs, not from one agent-orchestrator behavior calling everything else.
The decorator¶
from activegraph import behavior
@behavior(
name="contradiction_detector",
on=["object.created"],
where={"object.type": "claim"},
pattern="(c:claim)-[:contradicts]->(other:claim)",
view={"around": "event.payload.object.id", "depth": 1},
activate_after=1,
)
def contradiction_detector(event, graph, ctx):
for match in ctx.matches:
...
Every argument is a separate activation condition; the behavior fires when all of them hold:
on=— event types the behavior subscribes to. Most behaviors subscribe to a single type (object.created,goal.created, custom event names). Match-all is allowed withon=["*"]but rarely useful.where=— a dict-shaped filter on the event payload. Equality on values; nested keys via dotted paths.pattern=— a Cypher-subset pattern subscription. The behavior fires only when the pattern matches the graph at event time. Seepatternsfor the locked subset and grammar.view=— a scoped view of the graph passed to the behavior body via thectx.viewaccessor. Default is the full graph; narrow viaaround=+depth=to limit what the behavior reads.activate_after=— schedule the behavior to fire N events after the triggering event. Integer event count only; wall-clock units are refused (seeinvalid-activate-after).
The signature¶
event— the triggering event, withid,type,payload,actor,caused_by,timestamp.graph— the graph as it existed at event time, scoped by theview=argument.ctx— the runtime-bound context, with.matches(pattern bindings),.view(the scoped graph),.propose_object(the approval-gated add path), and a few framework-internal hooks.
The body mutates the graph by calling graph.add_object,
graph.patch_object, graph.add_relation, graph.remove_object,
or emits arbitrary events via graph.emit(type, payload). Each
mutation lands as an event in the log; downstream behaviors react.
The three behavior kinds¶
- Regular
@behavior(function or class) — the workhorse. Reacts to events, mutates the graph. Synchronous, deterministic. @llm_behavior— wraps a function whose return value comes from an LLM call. The framework handles the prompt assembly, the provider call, the cache, the tool loop, and the schema validation; the body receives the parsed LLM output and turns it into graph mutations. See the LLM behavior guide.@relation_behavior— attached to a relation type rather than an event type. Fires when an event affects an endpoint of the relation. Seerelations.
The determinism contract¶
Behavior bodies must be deterministic given their inputs. Same event, same graph state, same view → same mutations. This is the load-bearing assumption that makes replay and forking work. Two practical consequences:
- No
random, nodatetime.now(), nouuid.uuid4()in behavior bodies. If you need randomness or wall-clock time, get it from the event (which carries the recorded timestamp) or from the runtime's deterministic id generator (graph.ids). - No I/O outside the framework's primitives. Network calls go
through
@toolso the framework can cache and replay them. LLM calls go through@llm_behaviorso the prompt-hash cache works. Directrequests.getin a behavior body breaks replay determinism in a way the framework can't recover from.
The framework doesn't enforce determinism with static analysis; the
discipline is on the developer. The cost of breaking it is a fork
that produces a different result from its parent — see
replay-divergence-error.
The failure model¶
When a behavior body raises, the runtime catches the exception and
emits a behavior.failed event with the original exception's type,
message, and (for LLM/tool errors) the structured reason code.
The exception does NOT escape to your code — the loop continues,
other behaviors keep firing, and the operator sees the failure in
the trace.
Code that wants to react to failures subscribes to
behavior.failed. The retry-behavior pattern is the canonical
idiom:
@behavior(
on=["behavior.failed"],
where={"reason": ["llm.network_error", "tool.timeout"]},
)
def retry_transient(event, graph, ctx):
...
See failure-model for the
events-not-exceptions principle and
llm-behavior-error /
tool-error for the
LLM/tool failure shapes specifically.
What's related¶
graph— the world state behaviors react to and mutate.events— the append-only history behaviors subscribe to.relations— the typed-edge primitive and@relation_behavior.patterns— the Cypher-subset pattern subscription primitive.failure-model— what happens when a behavior body raises.- Writing behaviors — the how-to guide.