OpenProgram Docs

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_branch cases above. The write side of a fork is either pointing a user node's caller at something other than the active tail (set_head in the storage layer moves the UI pointer), or starting a new root through send_message (see §⑤). Read = three get_branch variants; 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:

  1. ContextEngine.prepare(agent, session, history, model, tools) (:885 → engine.py:194) renders the DAG history into LLM input messages (DAG rendering _build_messages_from_dag engine.py:558 by default; setting context.render="legacy" falls back to the commit chain). It returns a TurnPrep (context/types.py:102).
  2. When should_auto_compact(prep) (:896) is true — this is the path that actually fires when context exceeds the budget — snip runs 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)).
  3. The prompt is assembled (added once per loop, so the cached prefix is not broken by repetition).
  4. 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:

  1. push AgentEventTurnStart (pushed on every inner turn, :246).
  2. _stream_assistant_response calls the model (:260), emitting model.response_started (:442) and model.response_completed (:466).
  3. extract ToolCalls (:273). If there are tools, _execute_tool_calls (:278) runs and the results are appended back into the context (:288–290).
  4. push AgentEventTurnEnd.
  5. 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:

  1. push AgentEventToolStart (:675) + the plugin hook TOOL_BEFORE_USE (:686, best-effort).
  2. event-layer tool.before: make_event("tool.before",...) + emit (:695) — one event serving both the asynchronous observers and the synchronous consultation.
  3. 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 raises ToolGateDenied (:708) and the deny reason goes back to the model as an error tool result. It applies to subagents too — the gate sits outside the permission_mode approval wrapper, so bypass cannot switch it off (events/tool_gate.py:14–15).
  4. tool.execute(...) (:731), with a cwd snapshot + file checkpoint taken around it.
  5. push AgentEventToolEnd (:758/:766) + emit tool.after (:772).
  6. assemble a ToolResultMessage and 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, and head_id moves to the failed turn (:612/:618/:622).
  • Success path: persist_assistant_message (:676, dispatcher/persistence.py:31) writes the assistant message, then finalize_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 git commit_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 the predecessor/caller parent 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): typed subscribe(handler, types=...) (:159) + legacy channel on (:208); process singleton get_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 a TurnPrep (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_compact true → 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.
  • 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 by finalize_turn (dispatcher/finalize.py:283).
  • after_turn (engine.py:437, called from dispatcher/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).
  • Agent supports steer (interject mid-run), follow_up (append at wrap-up), and prompt.
  • 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 runtime tool_gate interception.
  • Subagents: run_agent_turn(sid, prompt, agent_id, branch_from, label) (sub_agent_run.py:41) is another call into process_user_turn (:96) with source="agent_spawn" and permission_mode="bypass" (:89). It returns an AgentTurnResult (:32, head_id = the new branch tip); label becomes a named branch via set_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.
  • to semantics (_parse_to): SID:HEAD (deliver to an existing branch = run one more turn from its head) or a branch name. Creating branches is the agent tool'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; plus emit_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_USE is explicitly marked future work (agent_loop.py:682–684).
  • The gate's three-state "ask" is not wired to ApprovalRegistry yet: Gate.ask is 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_loop are marked "no covering tests found".
  • A worker thread's ContextVar is unreliable: subagent.started/ended therefore pass the session explicitly (agent/job/runner.py:111–115), and send_message._resolve_parent carries a head fallback for the same reason.
  • ContextEngine.after_turn exists at two levels: the abstract base stub (engine.py:124) is pass; the concrete engine implementation (engine.py:437) is the one doing the work (usage feedback), called from dispatcher/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.

Last updated · 2026-10-10