Design overview#
This document is the authoritative design of the agent execution record: the data model, its edges and invariants, branching and spawn, context rendering, context assembly, and compaction. The model selection section compares alternatives and explains the choices. For the call-flow diagram, see
../agent-call-flow.svg. The visual rendering spec (layout, edges, legend, default visibility) isrendering.md— that file stays authoritative for drawing; this file covers semantics only.
1. Overview and Motivation#
Each Agent execution owns its DAG. Chat records user messages, model requests and directly dispatched tools; ordinary helpers create no durable nodes. Calling another Agent creates an independent DAG; the parent stores the invocation result and a child_session_id reference. Permission, cancellation and file-checkpoint ownership remain with the originating execution, independent of graph ownership.
Context inheritance and persistence are separate responsibilities. Inheriting Context does not turn ordinary computation into history. Retain caller, predecessor, reads and seq within each graph. Child caller edges and history anchors do not reference parent nodes; metadata expresses cross-graph relationships.
Model selection and trade-offs#
Why a session DAG#
The execution record serves three requirements together: nested calls, conversation
branches, and the exact nodes read by each LLM call. All LLM invocations use the
same node role, and loop iterations remain siblings. A single call tree cannot
also identify alternative conversation continuations; timestamps cannot identify
which branch a reply follows. The design therefore retains caller,
predecessor, and reads as distinct relationships, with seq providing order.
Alternatives considered#
| Model | Useful properties | Boundary for this design |
|---|---|---|
| Flat logs | Append-only events, simple collection | Nesting, branch ancestry and context reads require additional explicit relationships. |
| Span tree | Operation identity, parent-child calls, timing, status and attributes | Parentage describes calls; it cannot replace the separate conversation predecessor. |
| Timed nested intervals | Suitable for performance views such as Chrome Trace / Perfetto | Timing is a view of execution, not a durable definition of branch ancestry. |
| Session DAG | Calls, conversation branches and context reads in one record | Requires edge validation, indexes and explicit rendering rules. This is the selected internal model. |
The reference corpus includes commercial APM (Datadog, New Relic, Honeycomb and Lightstep), kernel tracing (Pixie and Cilium), and agent observability systems (Langfuse, Arize Phoenix/OpenInference, LangSmith, OpenLLMetry, W&B Weave and Braintrust). These are references for collection, inspection and export, not requirements to reproduce their storage models or product behavior. The normative external reference for span structure is the OpenTelemetry trace model.
Adopted and rejected choices#
| Choice | Decision and reason |
|---|---|
| A uniform operation record | Adopted: user, LLM and code nodes share Call, with roles and metadata describing differences. |
| A parent-child call relationship | Adopted as caller; nested functions and LLM calls retain their execution ancestry. |
| Replacing the conversation edge with time order | Rejected: predecessor is a top-level schema field required for forks, retries and branch traversal. seq orders records but cannot identify branch ancestry. |
| Treating context reads only as tracing events | Rejected internally: reads is an explicit list of node IDs used to explain context selection. Any telemetry export is a separate mapping. |
Adding caused_by as a prerequisite |
Not part of this schema; do not present a tracing link proposal as an implemented field. |
| Requiring an OTel SDK for persistence | Rejected: session storage and context reconstruction remain owned by OpenProgram. |
| GenAI attribute conventions | A reference for interoperable model and usage metadata; consulting the conventions does not imply an implemented exporter. See the official conventions. |
Costs and implementation boundary#
Keep complete records of semantic operations. Ordinary helpers are outside the canonical record policy; excluding them is not sampling away required history. Optional diagnostics remain separate. The call tree and conversation chain also need independent validation. Token, cost and evaluation metadata require explicit definitions rather than being inferred from timing.
The field mapping is Call.id for identity, caller for invocation ancestry,
predecessor for conversation ancestry, seq for deterministic order, reads
for context references, and metadata for adapter-specific data. created_at
is a wall-clock timestamp, not the ordering key. The following sections define
these contracts; the implementation-status appendix records remaining work.
2. Data Model#
Node (Call)#
One data structure covers everything. An LLM call is always the same kind of
llm node whether the user or a function triggered it.
Call:
id unique identifier
seq monotonically increasing integer, global temporal order (the sole sort key)
created_at wall-clock (for humans, not for sorting)
role "user" | "llm" | "code" ← determines rendering, not essence
name model id / function name / user name
input prompt / function args / None
output reply / return value / user text
status running | completed | error | cancelled
caller who invoked me (sub-call parent id); empty on nodes that were not sub-called
predecessor who came before me in the chat (conversation-chain parent id);
top-level schema field — the ONLY place this edge lives
reads which nodes this LLM call read (references for rendering, not a structural edge)
metadata token usage / model / source / expose / tool_call_id …
LLM leaf fields align with gen_ai.*
Defined in openprogram/context/nodes.py.
The three roles and ROOT#
| Scenario | role | caller | predecessor |
|---|---|---|---|
| Session root (ROOT) | user (special, display=root) |
empty | empty |
| User sends a message | user | ROOT | previous turn's llm reply; "ROOT" sentinel on the session's first node |
| LLM reply | llm | this turn's user (top-level) or the enclosing code node | this turn's user |
| LLM calls a tool | code | that llm node | — |
| User manually calls a function | code | empty | current branch head (or "ROOT" at root level) |
| Agent requests a model / direct tool | llm / code | owning operation in this graph | — |
Loops are not nodes. Actual model/tool operations in a loop are recorded; ordinary recursion adds no nodes. Another Agent records its internals in its own graph.
Status vocabulary#
One set for all nodes: running | completed | error | cancelled. Chat and
function paths use the same vocabulary; error nodes carry structured
type/trace metadata, and a user cancel writes cancelled, never error.
3. Edges and Invariants#
Two edges, never conflated#
| Edge | Field | Meaning | Who has it |
|---|---|---|---|
| Sub-call edge | caller |
who invoked me to execute | only genuinely sub-called nodes; a normal top-level assistant reply leaves it empty |
| Conversation-chain edge | predecessor |
who I follow in chat order | user / llm nodes |
Both are directed and acyclic. caller makes all top-level nodes converge onto
ROOT (one connected graph); predecessor expresses chat order and
distinguishes branches. They are orthogonal:
One graph (shared seq, unique root). Each node: caller(C) / predecessor(P)
ROOT
├ user1 seq0 C=ROOT P="ROOT" ┐ top-level users hang on ROOT via caller;
│ └ llm1 seq1 C=user1 P=user1 │ conversation order chains via predecessor:
├ user2 seq2 C=ROOT P=llm1 │ user2.P=llm1, user3.P=llm2
│ └ llm2 seq3 C=user2 P=user2 │ a fork = one predecessor with multiple
├ user3 seq4 C=ROOT P=llm2 ┘ conversation children
Why two edges: forks must be distinguished by predecessor. When the user
retries a message, two children sprout at the same position; seq alone cannot
tell which child follows which branch line. A single-edge model (caller + seq)
cannot express branching.
predecessor is a schema field#
predecessor is a top-level field on Call — the only storage location.
Serialization writes it top-level; there is no metadata mirror and no legacy
read path. Enforcing the edge in the schema, rather than validating metadata
after the fact, is what makes readers able to rely on it: a mislinked branch
cannot be un-corrupted by a linter, so the append path must refuse to create
one.
Write invariant#
Enforced in the store's append path (openprogram/store/session/session_store.py):
every ROOT-level conversational node (role user/llm, no real caller) must
carry a predecessor. A violating append raises PredecessorMissingError
instead of silently forking the session at ROOT. The legal exceptions:
- the session's first node and explicit root forks — these carry the
sentinel
predecessor="ROOT"(not empty), so retrying the first message creates a legitimate ROOT-level sibling that the invariant admits; - spawn branch roots — created only through
spawn_branch()(§4), withpredecessor=Noneandcallerpointing at the spawning node; ask_useranswer nodes — a user node with non-Noneinputis a callee reply inside a call, not a conversational turn;- compaction summary nodes — legal chain members per §8.
Read invariant#
get_branch and list_branches walk edges only — no caller fallback, no seq
stitching, no heuristics. A node without a predecessor must be a legal branch
terminus (spawn root, ROOT itself, or the session's first node); anything else
is broken data and raises BrokenPredecessorChainError with the offending node
id. Broken data surfaces; it is never guessed around.
4. Branches and Spawn#
Fork#
A branch is an alternative possibility at the same position. A branch node's
predecessor equals that of the node it replaces — the same predecessor
having multiple conversation children is a fork. No special node type exists:
| Scenario | Replaced node | Branch node | Shared edge |
|---|---|---|---|
| User resends a message | user2 (P=llm1) | user2' (P=llm1) | predecessor |
| LLM retry | llm1 (P=user1) | llm1' (P=user1) | predecessor |
| Tool retry | code (C=llm1) | code' (C=llm1) | caller |
Failure and retry#
An error is a terminal state, not a missing one. A turn that raises is
finalized exactly like one that succeeds: the node is written with
status=error, the turn is committed to git, and head stops on the error node.
The failed turn is a fact about the session, and the record says so.
Skipping finalization on the error path would leave the git timeline with a
hole precisely where something went wrong — the one place the history is worth
having. It would also leave a retry forking from a predecessor whose commit was
never written. A user cancel terminates the same way with status=cancelled.
The steps that finalization runs on an error path are the ones that keep the record whole: the git commit, the project commit, the shadow-git commit, and snapshot eviction. The steps that presuppose a completed reply — context-commit backfill, usage feedback, auto-titling — are meaningless for a turn that has no reply and are skipped.
Retry needs no separate mechanism. It is an ordinary fork: the retry node takes the failed node's predecessor, which is what makes it a sibling rather than a successor. Two consequences follow from the ordinary rules:
- The failed line is kept. The error node stays in the graph and stays reachable. Checking out its branch shows exactly what happened.
- The failed line is not in the retry's context.
render_contextwalks the active branch, and the error node is not on it. The retry never sees the error it is retrying. Nothing filters by status to achieve this — branch isolation already does it.
Spawn#
SessionStore.spawn_branch(session_id, caller_node_id, *, source, name=…) is
the only way to open a clean spawn root. It creates the branch-root user node
(predecessor=None, caller=caller_node_id, metadata.source,
metadata.spawn_branch_root=True), registers it as head, and returns its id.
Spawn call sites (task runner, collaboration messages, background agents) call
the primitive for clean starts. An inherited spawn is an ordinary exact fork:
its first user node has the requested node as predecessor.
A spawn branch root does not hang on ROOT: its caller points at the node
that initiated it, which keeps the single-connected-graph invariant via that
node. For a cross-session exact fork, source session S retains the attach card
beside its initiating node A, while target session T stores the new user node
with predecessor=M, caller=A, and
metadata.spawned_from_session=S. The graph projection cannot draw an edge to
a node in another session, so it places that external caller at ROOT for the
target view and marks the node spawn_remote; the source node is marked
spawn_out. The source card points to attach.session_id=T and the target
branch head. Cross-session spawning does not move either session's selected
HEAD; the target result is registered as a branch tip, and a later asynchronous
reply-back advances the source HEAD as an ordinary turn.
Spawn branches have clean context: get_branch on a spawn branch stops at the
spawn root and does not leak into the parent branch via the caller edge. The
chat view of a spawn branch shows only the branch's own history. The reverse
is also true: render_path from a parent-branch head does not descend into a
spawn branch via caller (see §6).
The completion notification anchors at HEAD#
An async sub-agent finishing writes two things back to the session that spawned
it: the attach pointer, which lands on the caller turn because that is where
the call was made, and a notification turn
(metadata.source = "task_followup"), which lands at the session's current
HEAD and advances it like any other turn.
The two anchor differently because they say different things. The attach pointer is a record of a call and belongs beside the call. The notification is a new turn in the conversation and belongs at its end.
This is what keeps N sub-agents from being answered N times over. Anchoring each notification at the node that spawned it makes them siblings of one turn — three sub-agents finishing produce three parallel branches, each with its own reply, and the user who sent one message watches it get answered three times. Anchored at HEAD they form one chain instead:
… → spawn turn → notice₁ → answer₁ → notice₂ → answer₂
The runner leaves TurnRequest.branch_from at INHERIT_PARENT and never
rewinds head before dispatching, so the dispatcher's ordinary append path does
the anchoring. Concurrency is handled by one lock per delivery session
(JobRunner._followup_lock): two sub-agents finishing in the same millisecond
still take their turns in sequence, and the second reads a HEAD that already
includes the first answer.
5. Head Pointer and Branch Management#
- head: the session tracks a
head_id— the tip of the currently active branch. Every write path advances it to a real node id; after a function call completes, head moves to the actual code node, never to a placeholder. A dangling head would make the branch walk unreachable and render an empty session. - get_branch(session_id, head_id): walks the predecessor chain from head to its terminus and returns the linear history of that branch.
- list_branches(session_id): enumerates leaves in the conversation
predecessor graph. Conversation nodes include user and LLM turns plus
top-level Program actions whose caller is empty or
ROOT. A node with any such successor is an ancestor, not another branch tip. Internal execution children, runtime/attach rows, and context rows do not continue the conversation and therefore do not remove their owner's tip. Parallel top-level Programs sharing one predecessor remain separate leaves. There is no special main-tip fallback: merged and non-conversation nodes stay excluded. Branch names live in session meta underbranches: {head_id: name}.
6. Context Rendering#
All context is retrieved from the graph through render_context. A single
rule determines which nodes enter the context:
A node is rendered if and only if its nearest ROOT-level ancestor (walking
calleredges upward) lies on the predecessor chain ofhead_id, and the frame/expose rules admit it.
Concretely, render_context walks the predecessor chain from head_id back
to the start of the branch, producing the branch spine; for each spine node it
then filters that node's caller-subtree through the frame and expose rules,
and the surviving nodes enter the rendering. seq is used only for ordering
and plays no part in this selection. Branch isolation is therefore an inherent
property of the walk — the engine performs no filtering of its own after
retrieval. The engine does exactly three things: resolve the head, call
render_context, and pass the result to render_dag_messages for translation
into provider messages.
ROOT is not a ROOT-level ancestor. Every top-level conversational node
carries caller="ROOT" (§3, which is what keeps the graph connected), so
expanding ROOT's caller-subtree would re-admit every sibling branch in the
session. A ROOT-level node is its own nearest ROOT-level ancestor; the walk
never expands ROOT itself.
Spawn-branch roots (metadata.spawn_branch_root) are likewise not entered via
the caller edge. A spawn branch is its own conversation; its result returns
through the return value / attach pointer. Expanding a spawn root from the
parent spine would leak the spawned instructions and verdicts into the
spawning branch (for example a Goal working agent reading its judge). The
exclusion is directional: rendering from a head inside the spawn branch still
sees the branch itself via the spine.
Frame semantics:
- Top-level chat (frame = −1): every spine turn is fully visible — accumulation; all prior turns of the branch are fed in, flat.
- Inside a function (frame = that code node's seq): pre-frame history plus
the function's own in-frame progress are visible; other functions' internals
are popped per their
exposesetting (ioby default exposes only input/output).
The primitive is pure: no disk writes on the read path. Anything that must touch disk (large-node spill) happens on the write path (§8).
Rendering tool nodes: a code node carrying metadata.tool_call_id (model
tool_use) renders as a real ToolCall/ToolResult pair grouped into its owning
llm node's assistant message; a code node without one (direct function call)
renders as a text pair. Two views project the same data: the chat stream
(top-level user + llm by seq, nesting folded) and the call tree (fully expanded
along caller, loop siblings folded ×N).
7. Context Assembly#
One system prompt, one assembler#
There is a single project-wide system prompt (identity + project memory +
unified tool list + skills), produced by one assembler —
context.build_system_prompt(agent_profile, tools, mode) — and shared by every
model call, whether top-level chat or inside a function body. The budget counts
the exact string that ships; assembler output equals wire output.
The prompt is constant by default from session start to finish. A constant prefix maximizes provider KV-cache hits, and a model inside a function gets the same project background as the chat model. Consequences:
- Not split into a chat prompt and a function prompt.
- No mutable tail segment: the tool list is unified too; once it varies per call site, the prefix varies and long-context caching collapses.
- Exceptions are a usage-layer choice: an individual call that wants a slim system prompt declares it explicitly at the call site, deliberately accepting the cache miss.
- Preventing wrong tool use inside a function (e.g. self-recursion) is solved
by situational guidance at the start of the user turn plus a recursion-depth
cap — never by varying the system tool list. See
../execution/agentic-self-recursion.md.
The prompt is recorded, not implied#
Whenever the assembled prompt's hash changes (session start, toolset change,
plan-mode toggle), the store appends a role=code node
name="context/system_prompt", caller=ROOT, output = the full text, on the
current branch. Rendering pins the latest such node on the spine as the wire
system message. Replaying any historical call reproduces the prompt that was
actually sent. No fourth role is introduced; context/* names are reserved and
hidden from the chat transcript (the same mechanism that hides summary nodes).
Memory prefetch lives in the user turn#
Prefetched memory renders as a prefix block inside the current user node's
wire message and is stored in that node's metadata (memory_prefetch). The
system prompt and tools segment stays byte-stable across turns (history
cache-hits), and replay sees exactly what the model saw. The block is not aged;
it dies with its turn like any other user content.
Multimodal content#
Images and files are node content, no different from text — no injection hook.
The node stores a reference (the body lives in the attachments directory), so
content is complete without bloating the search index. render_context
fetching a node fetches all of its content; render loads the image by
reference.
8. Compaction and Aging#
Summary nodes join the chain#
Compaction is an append-only insert. A summary node is role=llm,
name="context/summary", predecessor = the predecessor of the first node it
covers, metadata.covers_ids = the exact chain nodes it replaces (ids, not a
seq interval — sibling branches interleave seqs). Nothing else changes: the
kept tail keeps its ids and predecessors, no edges are rewritten, and HEAD
stays put (the append rule only advances on chain extension). Rendering
applies segment substitution (context/compaction.md §3): a chain that
contains the whole covered segment renders [summary, kept tail…]; any other
chain renders raw. Compaction is a rolling summary — each new one absorbs the
previous, extra_meta._last_summary_id marks the only active one, and
superseded summaries are inert relics.
The WebUI payload carries covers_ids on the summary row, extended with the
caller subtrees of covered turns (webui/graph_builder.py), so the renderer
draws the capsule and its fold without doing seq arithmetic — see
dag/rendering.md §9. Full specification: context/compaction.md.
The aging boundary only advances; rendering is exactly replayable#
The tail-turns aging boundary advances only at turn commit, never mid-turn (a
per-call rolling boundary breaks the cache prefix every call). Each llm node
records metadata.render_manifest = {policy_version, aged_before_seq, spilled: [...]} at the moment the call is made. Replaying a call means
rendering with the manifest's recorded policy, not today's — the same graph
renders the same bytes on any day.
Spill on write, one pipeline#
A node larger than the spill threshold is spilled to disk when it is recorded (once, deterministic), never when it happens to be rendered — the read path stays side-effect-free, as §6 requires. The DAG render is the only context pipeline: there is no fallback assembly path. If the render raises, the turn fails visibly with the error. Silent fallbacks hide broken pipelines; loud failure is the feature.
9. Storage Layer#
Sessions persist in a git-backed store (openprogram/store/session/):
- one
GitSessionper session on disk under<state>/sessions/<id>/; - one in-memory
SessionMemoryIndexper session, lazy-loaded, holding the node-by-id / children-by-predecessor indexes the walks use; head_idand branch names live in the session'smeta.json;- the store persists raw nodes + meta only; context commits belong to the commit subsystem.
The function body of an Agent method runs in a spawned subprocess
(fresh interpreter, not fork — the parent has PyTorch/libomp loaded and a fork
would SIGSEGV on the child's first BLAS call), so stop can SIGKILL the process
group. The subprocess writes its code subtree through its own SessionStore; the
parent invalidates its cache after execution to read the on-disk truth. The
SessionStore code subtree is the single source of truth for a function call;
the real-time WebSocket frame and the refresh load are both projections of it
and must produce the same card.
Appendix: Implementation Status#
Agent graph ownership and omission of ordinary helpers have source and regression coverage;
see execution recording for implementation status.
The existing data-model evidence below does not establish this change as released. The data model, edges,
invariants, spawn primitive, edge-pure branch walks (§2–§5), §6 path-native
membership, §7 (single assembler, context/system_prompt nodes,
memory-prefetch relocation), and §8 (covers_ids-based summary nodes, the
advance-only aging boundary, render manifests, write-path spill,
single-pipeline enforcement) all hold in code; openprogram/context/nodes.py,
openprogram/context/components.py,
openprogram/context/system_prompt_node.py,
openprogram/context/aging.py, openprogram/context/spill.py, and
openprogram/store/session/session_store.py are authoritative for details.
Compaction therefore no longer needs its exemption from the write invariant:
a summary node carries the predecessor of the range it covers and enters the
chain like any other node. context/summary is the one context/* name that
stays visible to the chat views, because its output is real conversation
content standing in for the range it replaces rather than pipeline machinery.
Five environment switches exist for ablation, all read at call time so a run
can move one variable without a reimport: OPENPROGRAM_TOOL_AGING,
OPENPROGRAM_TOOL_AGING_TAIL_TURNS, OPENPROGRAM_TOOL_AGING_MAX_RESULT_CHARS,
OPENPROGRAM_NODE_SPILL, and OPENPROGRAM_EXPOSE_DEFAULT (which moves only
what an unspecified expose= means — an explicit one always wins).
Related Files#
openprogram/context/nodes.py— Call schema + render_context, segment substitutionopenprogram/context/components.py— the one system-prompt assembler (§7)openprogram/context/system_prompt_node.py—context/system_promptrecording +context/*hiding (§7)openprogram/context/aging.py— ratcheted aging boundary + render manifests (§8)openprogram/context/spill.py— write-path large-node spill (§8)openprogram/context/persistence.py—covers_ids-based summary nodes (§8)openprogram/context/render.py— render_dag_messagesopenprogram/store/session/session_store.py— append invariant, get_branch, spawn_branch, list_branchesopenprogram/agent/dispatcher/__init__.py— chat entry, agent loopopenprogram/agentic_programming/runtime.py— function-body model callsrendering.md— visual rendering spec (authoritative for drawing)