Framework Overview — One Conversation from Input to Output#
This document ties the whole framework together: how single-turn and multi-turn context, the event layer, the agent run, collaboration, and the DAG mesh along the timeline of one conversation. File and symbol references point at the current code. Parts that are designed but not yet landed are collected in "Known Boundaries" at the end.
One sentence runs through the whole document: the subsystems are decoupled and
connected by a single process-wide event bus. The dispatcher, the agent loop,
tool execution, storage, and collaboration never call each other's UI or
broadcast logic. They emit an event, and whoever cares subscribes
(openprogram/events/bus.py EventBus.emit / EventBus.subscribe). The event layer has two lanes:
asynchronous observation (EventBus — nobody can stop what is already
happening) and synchronous consultation (tool_gate — the one place in the
framework that can stop a tool from executing).
Part One — The Lifecycle of One Conversation#
The data flow starts at "the user types a sentence". Each step records where it happens, what event it emits, how single-turn differs from multi-turn, and how a branch shows up.
Data flow (timeline)#
user types a sentence
│
▼ [entry] process_user_turn(req) openprogram/agent/dispatcher/__init__.py `process_user_turn` (sync; runs its own asyncio loop to completion)
│
├─▶ 1. session created / loaded :175 get_session / :177 create_session
│
├─▶ 2. history resolution (branches first appear here) :186–198
│ branch_from=INHERIT_PARENT → db.get_branch() walks the active branch's parent chain (= multi-turn)
│ branch_from=None → [] (root-level fork, starts empty)
│ branch_from="<node_id>" → db.get_branch(sid, node) (sibling fork, sees history up to the fork point)
│
├─▶ 3. user message persisted (written before the run, so a crash still leaves a record) :258–312
│ written as a DAG Call node with caller=ROOT (:298)
│ emit chat_ack(:314) + user_message(:326) ← live UI display
│ emit_safe("user.prompt_submitted") :346 ← onto the event bus
│
├─▶ 4. turn context bound (ContextVar) :366–436
│ _current_turn_id.set(assistant_msg_id) :379 ← every coroutine in the turn reads the same turn id
│ _store.set(SessionNodeWriter) :435 ← deep runtime / tools / Agent method write the same DAG
│ assistant_msg_id = user_msg_id+"_reply" :164
│ assistant placeholder row written + set_head :460; status="running" :464
│
├─▶ 5. ★ before the model call: the context engine runs ★ `openprogram/agent/dispatcher/loop_runner.py::run_loop_blocking`
│ a. ContextEngine.prepare(...) :885 ← DAG history rendered into LLM messages (TurnPrep)
│ b. should_auto_compact(prep)? :896
│ yes → snip (free: drop the oldest turn); still over → compact (LLM summarization) → prepare again (snip/compact/prepare retry path)
│ c. assemble the prompt (added once per loop, so the prompt cache prefix stays intact)
│ d. agent_loop([prompt], context, ...) :1074 → into the core loop (see below)
│
├─▶ 6. assistant message persisted persist_assistant_message :676 (persistence.py:31)
│
├─▶ 7. finalize (three context-subsystem jobs happen here) finalize_turn :711 / dispatcher/finalize.py:175
│ · head / token update
│ · ContextCommit backfill finalize.py:283
│ · ctx_engine.after_turn(...) finalize.py:308 → engine.py:437
│ ↑ after_turn feeds provider usage back into the tracker
│ · git commit_turn(:356) + project auto-commit (:369 commit_turn_changes)
│ · auto-title, usage feedback, snapshot eviction
│ db.update_session(status="idle"/"done") :727/:729
│
└─▶ emit chat_response(:735) → return TurnResult(:739)
Entry point: the dispatcher#
process_user_turn(req, *, on_event, cancel_event) is the framework's only
conversation entry point (openprogram/agent/dispatcher/__init__.py::process_user_turn). It is synchronous so a
channel worker thread can call it directly; internally it starts its own asyncio
loop and runs agent_loop to completion. It returns a TurnResult
(openprogram/agent/dispatcher/types.py TurnResult).
turn_id bound to a ContextVar — the other spine of the decoupling#
_current_turn_id.set(assistant_msg_id) (:379) is what makes depth-independent
attribution work. A ContextVar propagates along asyncio tasks, so any
coroutine within the turn — tool execution, Agent method,
send_message — reads the same turn id, which routes file backups and
sub-branch parent anchors to the correct assistant message. _store is bound the
same way (:435) so deep runtime code writes into the same SQLite DAG without
threading a handle through every signature. The finally block resets both on
success, exception, and early return alike.
Context assembly: single-turn / multi-turn / branch#
History resolution lives at :186–198, and this is where branching first
shows up in the data flow:
| Case | branch_from | History taken | Meaning |
|---|---|---|---|
| Normal append | INHERIT_PARENT |
db.get_branch(sid) walks the active branch's parent chain |
multi-turn: the whole history of the current branch |
| Root-level fork | None |
[] |
the LLM starts from nothing |
| Sibling fork | "<node_id>" |
db.get_branch(sid, node_id) |
history up to the fork point, uncontaminated by the active branch |
The only difference between single-turn and multi-turn is the length of the
chain get_branch walks back: single-turn holds just the user node written a
moment ago (hanging under ROOT), multi-turn holds a full parent chain.
The read side is the three
get_branchcases above. The write side of a fork is either pointing a user node's caller at something other than the active tail (set_headin the storage layer moves the UI pointer), or starting a new root throughsend_message(see §⑤). Read = threeget_branchvariants; write = move the head pointer or create a new root.
★ Before the model call: the context engine runs (the per-turn auto-compaction path) ★#
This layer is easy to overlook and happens on every turn. Step 5 is
run_loop_blocking (openprogram/agent/dispatcher/loop_runner.py), which runs before
entering agent_loop:
ContextEngine.prepare(agent, session, history, model, tools)(:885→engine.py:194) renders the DAG history into LLM input messages (DAG rendering_build_messages_from_dagengine.py:558by default; settingcontext.render="legacy"falls back to the commit chain). It returns aTurnPrep(context/types.py:102).- When
should_auto_compact(prep)(:896) is true — this is the path that actually fires when context exceeds the budget —snipruns first (free: drop the oldest turn); if that is not enough,_ctx_engine.compact(...)(LLM summarization) runs, then prepare runs again (a three-stage retry at (snip/compact/prepare retry path)). - The prompt is assembled (added once per loop, so the cached prefix is not broken by repetition).
agent_loop([prompt], context, config, ...)(:1074) enters the core loop.
"Compact once at the start of the turn" and the "idle-gap microcompact" at the end are two different compaction paths (see §③).
Core loop: call the model → tools → feed results back → repeat#
agent_loop (openprogram/agent/agent_loop.py) builds an EventStream; the inner loop of
_run_loop (:205, loop at :236) does:
- push
AgentEventTurnStart(pushed on every inner turn,:246). _stream_assistant_responsecalls the model (:260), emittingmodel.response_started(:442) andmodel.response_completed(:466).- extract
ToolCalls (:273). If there are tools,_execute_tool_calls(:278) runs and the results are appended back into the context (:288–290). - push
AgentEventTurnEnd. - break when there are no more tools and no steering / follow-up.
Chat turns have no inner-loop hard cap (Codex / DeepSeek style). runtime.exec still defaults to 20.
Tool execution: the tool.before interception#
_execute_tool_calls (openprogram/agent/agent_loop.py) does this for each tool call:
- push
AgentEventToolStart(:675) + the plugin hookTOOL_BEFORE_USE(:686, best-effort). - event-layer tool.before:
make_event("tool.before",...)+emit(:695) — one event serving both the asynchronous observers and the synchronous consultation. - synchronous gate:
decide_tool_gate(before_ev)(:701), the one place in the framework that can stop a tool (events/tool_gate.py:53). Any gate returning deny blocks the call (reasons merged); a gate that raises counts as allow (fail-open). A blocked call raisesToolGateDenied(:708) and the deny reason goes back to the model as an error tool result. It applies to subagents too — the gate sits outside thepermission_modeapproval wrapper, sobypasscannot switch it off (events/tool_gate.py:14–15). tool.execute(...)(:731), with a cwd snapshot + file checkpoint taken around it.- push
AgentEventToolEnd(:758/:766) + emittool.after(:772). - assemble a
ToolResultMessageand feed it back (:792). Steering is checked after each tool (:806); when it hits, the remaining tools are skipped and the steering message is fed back instead.
Persistence + finalize#
- Error path (
:605–639): the error is folded into the placeholder row or a standalone error node, andhead_idmoves to the failed turn (:612/:618/:622). - Success path:
persist_assistant_message(:676,dispatcher/persistence.py:31) writes the assistant message, thenfinalize_turn(:711,dispatcher/finalize.py:175) runs. The context subsystem does three things inside finalize: (1) head/token update; (2) ContextCommit backfill (finalize.py:283, freezing this turn's compaction decision into an immutable per-turn commit); (3)after_turn(finalize.py:308→engine.py:437, usage feedback). Then gitcommit_turn(finalize.py:356) + the project auto-commit (:369).
DAG updates run throughout, not as a separate step#
The user node (:298), the assistant placeholder, every tool result, and the
nodes inside an Agent method all land in the same SessionNodeWriter through
the _store ContextVar. At the end of the turn commit_turn
(openprogram/store/session/session_store.py::commit_turn) commits the whole working tree as one turn — append-only,
with no mutable "current state" mirror file, so two agents writing concurrently
never collide on the same file.
Part Two — Layer Reference#
Event table#
The events actually in flight. Two kinds: typed events on the bus (asynchronous
observation + synchronous consultation), and ws.frame envelopes passed through
to the front end.
| Event type | Emitted by (file and symbol) | Consumed by | Notes |
|---|---|---|---|
user.prompt_submitted |
dispatcher :346 |
proactive observer (proactive/state.py:61) |
user message committed |
tool.before |
agent_loop :695 |
tool_gate (synchronous) + observers | the only interception point |
tool.after |
agent_loop :772 |
observers | carries is_error |
model.response_started |
agent_loop :442 |
observers | model stream begins |
model.response_completed |
agent_loop :466 |
proactive wrap-up policies | wrap-up timing check |
subagent.started / .ended |
task/runner :115 (origin=system, session passed explicitly because a worker thread's ContextVar is unreliable) |
observers | subagent state funnel |
branch.message_sent |
send_message :266 |
observers + ws.frame |
from/to |
question.asked |
questions :164 (also emit_ws_frame :161 for the front-end card) |
channels question bridge (_question_bridge.py:43) |
both on the bus and as a ws frame |
question.replied |
questions :275 (resolve_question_and_broadcast:262) |
front end | ws frame only |
question.rejected |
questions :173/:276 |
front end (handled as "withdrawn") | ws frame only |
context.compacted |
context engine | UI / observers | compaction happened |
file.changed |
functions watcher and others | observers | file change |
channel.message_inbound |
channels | observers | inbound message |
memory.ingest_started / .ended |
memory | observers | memory ingestion |
skills.changed / plugins.update_available / sessions.listed / branches.listed |
various subsystems | UI / observers | lists and available updates |
ws.frame (openprogram/events/bus.py) |
external sources via emit_ws_frame |
openprogram/webui/server.py (broadcast verbatim) |
passthrough envelope: external sources never touch webui _broadcast directly |
Subscriber sites: the proactive engine subscribes to all events and
filters by on (proactive/engine.py:145); the webui subscribes only to
ws.frame (openprogram/webui/server.py); the channels question bridge subscribes only to
question.asked (_question_bridge.py:43).
Event contract: emit is fire-and-forget, and a handler that raises never
propagates back to the emitter (openprogram/events/bus.py prints to
stderr); an async handler with no running loop is skipped; emit_safe (:96)
wraps the whole thing in try/swallow. The event layer never breaks the caller's
code path.
① Storage — SessionStore, branch = (session, head)#
Responsibility: one git repo plus an in-memory index per session;
append-only, with no mutable current-state mirror.
Key files: store/session/session_store.py, store/session/memory_index.py.
Mechanisms:
_open(sid)(:413) returns(GitSession, SessionMemoryIndex), LRU-cached and evicted by capacity; the index can be rebuilt losslessly from git.- branch = (session, head pointer):
get_branch(sid[, head])(:817/memory_index.py:101) walks thepredecessor/callerparent chain;set_head(:880) moves a single-valued UI pointer stored in meta. A fork is simply another chain walked from some node, and the two never contaminate each other. commit_turn(sid, msg)(:504) commits the working tree as one turn. Events:sessions.listed,branches.listed.
② Event layer — bus + tool.before interception#
Responsibility: process-wide fan-out that decouples every subsystem.
Key files: openprogram/events/ (bus.py / tool_gate.py), agent/questions.py.
Mechanisms:
EventBus(:129): typedsubscribe(handler, types=...)(:159) + legacy channelon(:208); process singletonget_event_bus()(:241, double-checked locking).- synchronous tool.before interception:
register_tool_gate(events/tool_gate.py:38) /decide_tool_gate(:53), taking the strictest verdict and failing open. A gate must be fast — no LLM calls, no slow IO. - Question subsystem:
QuestionRegistry(questions.py:61) — a process-wide pending table, claim-once, thread-safe. Events: see the table above.
③ Context engine — assembly / compaction / ContextCommit#
Responsibility: render DAG history into LLM input and make compaction
decisions.
Key files: context/engine.py, context/microcompact.py,
context/references.py, context/render.py, context/commit/,
context/rules/, context/tool_aging/; finalize lives in
agent/dispatcher/finalize.py, not under context/.
Mechanisms:
ContextEngine.prepare(...)(engine.py:194) returns aTurnPrep(context/types.py:102). It renders from the DAG by default (_build_messages_from_dag:558) and falls back to legacy on failure (:218–220), so one bad commit does not take down the whole turn.- Two compaction paths (distinct):
- turn-start auto-compact (primary):
should_auto_compacttrue →snip(free: drop the oldest turn) → still over,compact(LLM summarization) → re-prepare. Lives in_run_loop_blocking, before each model call (Part One, step 5). - idle-gap microcompact (secondary):
microcompact.py:76, triggered only after more than 3600s idle (GAP_THRESHOLD_SECONDS:45— cleanup at zero extra cost once the prompt cache has expired). It keeps the 5 most recent tool_results and replaces older large ones with placeholders. It is non-destructive: it returns a copy and does not touch DAG nodes.
- turn-start auto-compact (primary):
- Reference scan:
ReferenceTracker.build(references.py:114) is a cheap substring scan marking tool_results referenced by later text so they escape compaction. Its results currently feed logging only; the ContextCommit rules do not consume them yet (see Known Boundaries). - ContextCommit (
commit/types.py:104): each turn's compaction decision frozen into an immutable commit, backfilled byfinalize_turn(dispatcher/finalize.py:283). after_turn(engine.py:437, called fromdispatcher/finalize.py:308): usage feedback. Events:context.compacted.
④ Agent run — loop + tool registration + subagents#
Responsibility: drive the model↔tool loop, execute tools, spawn subagents.
Key files: agent/agent_loop.py, agent/agent.py, agent/sub_agent_run.py,
providers/utils/event_stream.py, agent/management/gating.py.
Mechanisms:
EventStream(event_stream.py:15): async-iterable plus a terminal value; provider dict events are normalized into typed ones.agent_loop/_run_loop(:114/:205, hard cap 50 at:226) +_execute_tool_calls(:654).Agentsupportssteer(interject mid-run),follow_up(append at wrap-up), andprompt.- Static admission for tools / skills / MCP:
gate(name, disabled, allowed, categories)(management/gating.py:38, fnmatch, resolved in the order disabled→allowed→categories). This is static admission — agent.json declaring who may use what — and is a separate mechanism from ②'s runtimetool_gateinterception. - Subagents:
run_agent_turn(sid, prompt, agent_id, branch_from, label)(sub_agent_run.py:41) is another call intoprocess_user_turn(:96) withsource="agent_spawn"andpermission_mode="bypass"(:89). It returns anAgentTurnResult(:32,head_id= the new branch tip);labelbecomes a named branch viaset_branch_name(:109). Events:model.response_started/completed,subagent.started/ended(the last pair emitted by task/runner).
⑤ Collaboration — send_message + cross-session + guards#
Responsibility: deliver messages between branches and sessions, run the
target branch, and bring the reply back.
Key files: programs/tools/send_message/send_message/send_message.py,
programs/tools/send_message/list_agents/list_agents.py.
Mechanisms:
send_message(message, to, agent_id)→_send_message_impl.tosemantics (_parse_to):SID:HEAD(deliver to an existing branch = run one more turn from its head) or a branch name. Creating branches is theagenttool's job (spawn / fork off a node);to="new"syntax is rejected with a redirect to it.- The parent anchor
_resolve_parent(:74) reads the dispatcher's session/turn ContextVars and falls back to the session head when the turn id is missing. - Delivery is always asynchronous: it is handed to the task runner
(
run_agent_turn_async); when the run completes it writes an attach pointer and dispatches a follow-up back to the initiating session, so replies flow back automatically. - Guards: the message budget
agent.max_messages(default 8,depth.py; children inherit count+1, and an A↔B round trip counts too); a self-reference guard (messaging your own current turn is rejected outright); the target session must already exist and is never silently created; and tool.before interception applies (an attended gate can block it). Events:branch.message_sent; plusemit_ws_frame("branch_message",...)(_emit_branch_ui), which renders a "sent" line in the initiator's chat stream.
Known Boundaries#
- tool.before observes and denies but cannot mutate or veto by plugin: the
plugin hook
TOOL_BEFORE_USEis explicitly marked future work (agent_loop.py:682–684). - The gate's three-state "ask" is not wired to ApprovalRegistry yet:
Gate.askis noted as pending that connection (proactive/actions.py:29), and the "critical fail-closed" tier awaits the rules layer (events/tool_gate.py:13). - Reference-scan results are not consumed by ContextCommit rules: they feed
logging only (
engine.py:204–212). - The DAG render keeps a fallback path alongside the normal one: a bad commit
falls back to legacy (
engine.py:218–220). - The core entry points lack covering tests: several paths through
process_user_turn/agent_loopare marked "no covering tests found". - A worker thread's ContextVar is unreliable:
subagent.started/endedtherefore pass the session explicitly (agent/job/runner.py:111–115), andsend_message._resolve_parentcarries a head fallback for the same reason. ContextEngine.after_turnexists at two levels: the abstract base stub (engine.py:124) ispass; the concrete engine implementation (engine.py:437) is the one doing the work (usage feedback), called fromdispatcher/finalize.py:308.
Anchor Quick Reference#
Dispatcher entry openprogram/agent/dispatcher/__init__.py::process_user_turn; turn_id binding :379;
history/branch resolution :186–198; user node write :298; prepare /
auto-compact before the model call openprogram/agent/dispatcher/loop_runner.py::run_loop_blocking;
finalize :711 / dispatcher/finalize.py:175 (ContextCommit backfill :283,
after_turn in openprogram/context/engine.py). Event bus openprogram/events/bus.py::EventBus;
tool.before interception agent_loop.py:695/:701 + events/tool_gate.py:53. Context
engine.py:194 plus the two compaction paths (auto-compact
openprogram/agent/dispatcher/loop_runner.py::run_loop_blocking / microcompact.py). Agent loop
agent_loop.py:114/205/654; subagents sub_agent_run.py:41. Collaboration
send_message.py:186/393, depth cap :35.