Agent Call Flow (Authoritative Design)#
This is the core framework for agent calls — every turn / LLM call follows this flow. Future features are inserted at the corresponding nodes on top of this; the skeleton stays unchanged.
Overview Diagram#
agent-call-flow.svg depicts the target (after unification): two entry points (user message / inside a function body) converge onto one shared trunk — ① render_context reads the context → ② open_call_node → ③ agent_loop → ④ close_call_node; calling exec from inside a tool body returns to the top of the trunk (nesting). The special handling of each entry point (chat's front-end streaming / title / compaction, the shared retry / metering) hangs off the trunk as optional hooks. The bottom lists "what is already shared today" and "the 7 steps the merge requires".
Current state vs target: the prose below describes the current state (the two entry points each manage their own periphery, each write their own records, and only converge at agent_loop) — serving as the starting reference for the merge. For the concrete steps to unify into the single trunk above, see section 8 of
dag/overview.md.
The skeleton in one sentence (current state): two peer entry points (dispatcher · runtime.exec) each manage their own periphery and write their own DAG records, converging only at the shared agent_loop; exec sits below the loop → a tool body can call the LLM again, forming nesting.
Real Topology: Converging Fork, Not Three-Level Nesting#
Code evidence (agent_loop has only two call sites in the entire repo: dispatcher/__init__.py:902 and agent/agent.py:418): the dispatcher does not go through exec, and exec does not go through the dispatcher. They are two peer entry points, sharing only agent_loop.
Entry A: user message → dispatcher ──directly─────────────┐
├─→ agent_loop (shared engine)
Entry B: inside @agentic_function body → runtime.exec ──via AgentSession─┘
| Entry | Responsibility | Path to agent_loop | What DAG record it writes | Implementation |
|---|---|---|---|---|
| A · dispatcher | turn lifecycle: session, user node, attach Runtime, resolve model, parse tools, persist, front-end broadcast | directly agent_loop(...) (__init__.py:902) |
top-level reply → assistant session message (SessionDB, persistence.py:207) |
agent/dispatcher/__init__.py |
| B · runtime.exec | one LLM call: open/close the llm node, build the context | via AgentSession → session.run (runtime.py:1576) → agent.py:418 |
this call → role=llm DAG node | agentic_programming/runtime.py |
| shared · agent_loop | tool loop engine: call model → run tools → feed back → loop until plain text | — | the tool's code node called_by → the current llm node | agent/agent_loop.py:114 |
Why Two Entry Points, Not One#
dispatcher and exec serve two different scenarios, and their peripheral plugins do not overlap: dispatcher needs session management + front-end WebSocket broadcast + title/compaction; exec needs the DAG llm node + AgentSession retry-rollback. A hard merge would saddle one side with logic the other doesn't need; folding them in practice hits model forking and node duplication (see "Why the dispatcher is not folded into runtime.exec" below). agent_loop is shared, because the "call model → run tools → loop" engine is identical for both.
Key: exec Sits Below agent_loop, So It Can Nest#
When the dispatcher runs a turn and the model calls an @agentic_function tool (e.g. wiki_agent) → the tool body calls runtime.exec → which starts another agent_loop. So exec is both "Entry B" and is called in reverse by a tool inside agent_loop:
dispatcher → agent_loop → model calls tool → @agentic_function body → runtime.exec → agent_loop (nested) → ...
This is the fundamental capability of agentic programming (a function body can nest LLM calls), and it is the source of wiki_agent's recursion. If exec sat above the loop, a tool could not call the LLM itself.
The Ordered Steps Inside Each Node#
A · dispatcher: 1 create/load session (read history along the active branch) → 2 write the user node → 3 attach Runtime (_store + _current_runtime) → 4 resolve model (agent profile + override) → 5 parse tools (channel/plan/approval wrapping) → 6 call agent_loop directly → 7 persist + finalize (assistant node, title, auto-compact).
B · runtime.exec: 1 open the llm node (running, _call_id points to it, wrapped in an outer timeout/retry loop) → 2 build the context [a select history nodes via render_context → b render into messages + current turn → c resolve the toolset toolset/policy/unattended-deny → d assemble system + skills → e create AgentSession (select stream_fn)] → 3 call agent_loop via AgentSession → 4 close the llm node (backfill output, success).
shared · agent_loop: before each round of calling the model [a convert_to_llm → b memory prefetch → c deferred-tool re-split] → call the model / stream → check tool_use? → yes: run the tool → feed the result back → call the model again; no (plain text): exit the loop.
Side-by-Side Comparison#
| OpenCode | OpenClaw | Hermes | OpenProgram (after unification) | |
|---|---|---|---|---|
| Abstraction for one LLM call | Step (one node, tool loop inside) |
runId pairing (llm_input/llm_output, tool nested inside) |
no hierarchy, flat list | exec (one llm node, tool loop inside) |
| Recording method | paired: Step.Started → Step.Ended | paired: llm_input hook → llm_output hook | append-only, no marking | paired: write running → backfill output |
| Tool-call attribution | ToolPart inside the Step (sub-part) | nested under the same runId (sub-hook) | peer tool message | the code node's called_by points to the llm node (child node) |
| Chat vs programmatic call | unified (one path) | unified (same hook system) | unified (one run_conversation) | unified (both go through runtime.exec) |
OpenProgram adopts the OpenCode/OpenClaw model: one call = one node, paired writes, tools are child nodes.
Problems (Current State)#
Right now "making one LLM call" has three paths:
| Path | Entry | tool loop | Writes DAG llm node |
|---|---|---|---|
| regular chat | dispatcher → agent_loop | managed by agent_loop | dispatcher writes a SessionDB message (not a DAG llm node) |
| exec legacy | exec → self._call() |
none | exec writes the llm node |
| exec providers | exec → session.run → agent_loop |
managed by agent_loop | not written (bug) |
Concrete Symptoms#
-
The exec providers path does not write the llm node.
_call_via_providersreturns immediately aftersession.runreturns, without calling_append_model_call_node. wiki_agent takes this path, so in the DAG wiki_agent connects directly to wiki_agent with no LLM node in between. -
The dispatcher path writes something other than a DAG llm node. The dispatcher calls
persist_assistant_message, which writes a SessionDB assistant message (with token stats), not a DAGCall(role=llm)node. -
The legacy path has no tool loop. The model can only return plain text and cannot call tools.
Design#
Core Principle#
One runtime.exec = one llm node.
The llm node and the code node are the same abstraction: one call, running on entry, output backfilled on exit. Whatever happens inside (how many rounds the tool loop ran, which tools were called) is an internal process and is not split into multiple nodes.
enter exec → write the llm node (status=running, output=None)
inside exec → agent_loop runs the LLM + tool loop
(the tool's code node called_by points to this llm node)
exec returns → backfill the llm node (output=final reply, status=success)
Call-Tree Example#
The wiki_agent recursion scenario, DAG after unification:
llm (dispatcher calls exec, model decides to call wiki_agent)
code wiki_agent d1 (tool execution)
llm (wiki_agent internally calls exec, model calls wiki_agent again)
code wiki_agent d2 (recursion)
llm (yet another exec)
code wiki_agent d3
...
There is an llm node between every two code nodes, so the call chain is complete.
Unified Path#
runtime.exec(content=[...])
→ write the llm node (status=running, output=None)
→ build the context (render_context + render_dag_messages + content)
→ call the LLM, run the tool loop until the model returns plain text
(for tools the model calls inside the tool loop, their code node called_by points to this llm node)
→ backfill the llm node (output=final reply, status=success)
→ return text
Changes Per Layer#
dispatcher: no longer calls agent_loop directly; instead calls runtime.exec. Retained responsibilities:
- write the user node
- set the session context (ContextVar)
- call runtime.exec
- turn-level cleanup (title generation, compaction trigger)
process_user_turn (after change)
→ write the user node (unchanged)
→ set _store / _current_runtime (unchanged)
→ runtime.exec(content=user message) ← change here
→ finalize
runtime.exec: unified into a single path.
- legacy
call=my_funcis wrapped into a lightweight provider adapter and uniformly goes through_call_via_providers _call_via_providersgets the llm node write added (paired: write running on entry, backfill on return)- delete the legacy branch
agent_loop: unchanged. A pure "LLM call + tool execution" engine, unaware of the DAG.
Detailed Analysis of the Current State#
Path 1: Regular Chat (dispatcher)#
user sends a message
→ process_user_turn (dispatcher/__init__.py:96)
→ write the user node to the DAG (db.append_message)
→ set the _store ContextVar
→ _run_loop_blocking (dispatcher/__init__.py:640)
→ build AgentContext (tools, system_prompt, history)
→ agent_loop([prompt], context, config) (agent_loop.py:232)
→ _stream_assistant_response → LLM reply
→ if tool_use → _execute_tool_calls → tool.execute
→ if the tool is an @agentic_function → the wrapper writes a code node
→ tool returns → agent_loop continues
→ eventually get a plain-text reply
← return final_text
→ persist_assistant_message (persistence.py:31)
→ write the assistant message to SessionDB (not a DAG llm node)
→ finalize
Path 2: runtime.exec legacy#
inside an @agentic_function body, call runtime.exec(content=[...])
→ exec (runtime.py:789)
→ self._call(content) → user-defined function, returns text
→ _append_model_call_node(reply=...) → write the llm node to the DAG
← return text
Path 3: runtime.exec providers#
inside an @agentic_function body, call runtime.exec(content=[...])
→ exec (runtime.py:789)
→ _call_via_providers (runtime.py:1306)
→ build AgentSession
→ session.run(current) → internally runs agent_loop (same code as Path 1)
← return the final assistant message
→ return _assistant_text(final) ← no llm node written!
← return text
Node Writing and the Callable Path#
exec writes the llm node in pairs#
exec opens and closes the llm node in a pair (_open_model_call_node /
_close_model_call_node) on both the sync and async paths, so an LLM call made from
inside a function body appears in the session DAG. In _call_via_providers, _call_id
switches to the llm node before session.run, so the tool loop's tool code nodes have
their called_by pointing at the llm node
(test_tool_loop_subcall_attributes_to_llm_node).
The callable path goes through a provider model#
Runtime(call=fn) is wrapped into a provider model and goes through
_call_via_providers like any other model, so there is no separate legacy call
branch:
| Piece | What it is | File |
|---|---|---|
CallableModel adapter |
sync+async, converts pi-ai messages back to content to call the user fn, returns a single AssistantMessage, no tool loop; reinstates response_format→prompt suffix |
openprogram/providers/callable_model.py |
| Runtime wiring | in Runtime.__init__, call= → self.api_model = CallableModel(call); a callable runtime forces toolset="none" |
runtime.py init |
_call_via_providers does not ignore content: _render_history_messages(content)
treats content as the current turn (runtime.py:1451), and with no store,
_build_pi_context(content) (:1456). The adapter therefore needs no special
handling of content; AgentSession hands history+current to the model, and the adapter
converts back to content to call the user fn.
stream_fn injection#
The stream_fn parameter threads through exec → _call_via_providers →
AgentSession.init → AgentOptions, so exec can inject a stream
(test_exec_stream_fn_injection).
Why the dispatcher is not folded into runtime.exec#
Having _run_loop_blocking call runtime.exec instead does not work, for four reasons:
- The model would fork: the dispatcher uses
_resolve_model(agent_profile, req.model_override)(agent profile + the model the user picked), whereas the attached runtime comes fromcreate_runtime()with no args via auto-detection, so theapi_modelis inconsistent. Going through exec would use the wrong model. - The context would conflict: the dispatcher uses the context-engine to prepare messages (
prep.agent_messages), while exec renders history from the DAG itself; the two sets would clash. - The streaming events are incompatible: exec's
on_streamemits a flat dict, while the dispatcher'son_eventexpects a webui envelope. - Most critical — duplicate nodes would be written: trying to add an llm node in the dispatcher with
_open/_close_model_call_nodeproduced[user, assistant, assistant]in the DAG — because the dispatcher's assistant session message (persist_assistant_message) is itself the DAG record of the top-level LLM call. Adding another llm node duplicates it.test_dispatcher_integration.py::test_real_loop_text_onlycaught this duplication directly.
Conclusion: the dispatcher's top-level LLM call already has a DAG representation (the role=assistant session-message node), with tool calls hanging below it. There is no need — and it would be wrong — to add another llm node. The "code→code missing an llm node" problem occurs only in the tool loop of exec inside an @agentic_function, which the paired llm-node write above covers. The dispatcher stays as is.
The relationship between dispatcher and exec is not "dispatcher goes through exec" but rather two parallel LLM-call entry points, each recording its top-level call into the DAG (dispatcher → assistant session node; exec → llm node), sharing the lower agent_loop engine and the DAG node-write API. This is consistent with other frameworks (OpenClaw also has a separate dispatcher layer).
The Agent Class: Left Alone#
Agent sits below exec (exec → AgentSession → Agent → agent_loop); it is a loop driver, not a parallel LLM path, and is constructed in only one place (session.py:94). Folding it would amount to rewriting the loop, with no payoff.
Event Layer: No Changes#
tool.before fires in only one place, _execute_tool_calls (agent_loop.py:518), and all paths go through it. CallableModel has no internal tool loop (the user fn only returns a str), so it will not duplicate or lose events.
Sensitive Points#
- dispatcher test seam: with stream_fn threaded through four layers,
_run_loop_blockingis still the patch entry, and a test's stream_fn flows through exec to the model.test_dispatcher_dag_attach.pyreplaces_run_loop_blockingwholesale and is unaffected. - prompt-cache prefix stability: an override must not break the DAG-prefix cache.
- response_format: the JSON-mode of
claude_call/gemini_callrelies on the adapter to reinstate the suffix.
Unifying the "record one model reply" write#
Two Ways to Write the Same Thing#
"Recording a model reply" has two paired node-write implementations (open a running placeholder → backfill the result), split by entry point:
| Entry | Open placeholder | Backfill | Implementation |
|---|---|---|---|
| dispatcher (chat) | insert_placeholder (_turn_lifecycle.py:65) |
persist_assistant_message (persistence.py) |
dispatcher-local |
| exec (code) | _open_model_call_node (runtime.py:631) |
_close_model_call_node (runtime.py:656) |
runtime primitive |
The two have identical structure (open→close pairing), but one is scattered across the dispatcher and the other lives in runtime, so the same head-and-tail write exists twice.
Key Facts (That Make Unification Easy)#
The storage layer has no ROLE_ASSISTANT — only user/llm/code (context/nodes.py:36-40). The chat reply is already stored as ROLE_LLM: _msg_to_node maps role="assistant" to ROLE_LLM, and _node_to_msg reads it back and defaults to restoring it to "assistant" (_msg_adapter.py). exec's _open_model_call_node also writes ROLE_LLM, and it too reads back to "assistant" by default.
The two primitives already use the same role at the node layer. The only difference is that the dispatcher writes 4 extra pieces of metadata that exec's bare primitive does not. Serialization has a single chokepoint _node_to_msg → what the front-end reads is still "assistant".
One general paired primitive, used by both entry points#
exec's paired primitive becomes a general paired primitive able to hold the fields the dispatcher needs, both entry points use it, and the duplicated placeholder/persist mechanism goes away.
The unified primitive must retain these 4 things (otherwise the front-end / branching / metering will break):
| Field | Who needs it | Risk |
|---|---|---|
extra.blocks (the order of thinking/text/tool cards) |
the front-end bubble body (conv-mapper.ts) | highest |
| token columns + token_model | metering UI | high |
metadata.parent_id=user_msg_id |
active-branch rebuild for branch/fork/rewind | high |
cancelled/completed terminal state (exec currently writes success) |
partial output when the user stops | medium |
Primitive signature upgrade (optional params; exec doesn't pass them, dispatcher does):
open_model_call_node(*, role="llm", parent_id=None, content_text="", model=None) -> node_id
close_model_call_node(node_id, *, reply, status="success", blocks=None, usage=None)
The front end needs no changes: _node_to_msg still outputs "assistant", and the fields it reads (blocks/token/parent_id) are all still present, written by the unified primitive.
Implementation Status#
In place:
- exec's paired llm-node write on both the sync and async paths, with
_call_idswitching so tool code nodes attribute to the llm node - the
CallableModeladapter and the Runtime wiring that routescall=through_call_via_providers stream_fnthreaded through exec → _call_via_providers → AgentSession → AgentOptions
The unified paired primitive is not yet adopted by the dispatcher. It lands in this order:
- Upgrade
_open/_close_model_call_node, adding optionalparent_id/blocks/usage/statusparams (exec calls unchanged, default behavior unchanged) - Change the dispatcher's
insert_placeholderto callopen_model_call_node(role="assistant", parent_id=user_msg_id) - Change the dispatcher's
persist_assistant_messageto callclose_model_call_node(blocks=..., usage=..., status="completed"/"cancelled")— keep only the field assembly, delete its own append/update - Delete the duplicated node-write logic in the placeholder/error-fold of
_turn_lifecycle.py - Verify: webui end-to-end chat (bubbles, thinking/tool cards, tokens, stop, fork) all working + full pytest
Tests that reference the removed legacy-call seam: test_openai.py:61, test_anthropic.py:63, test_gemini.py:68 drop _uses_legacy_call() is False; test_decision.py:24, test_loop_options.py:153, test_dispatcher_dag_attach.py:89 drop the _uses_legacy_call→True override and keep the _call override. Behavioral ones needing re-verification: test_runtime_exec_dag.py:34, the _mock_call in test_functions.py (branches on content shape; the adapter passes content through verbatim), and the echo_call/noop_call in conftest.py.
Related Files#
openprogram/agentic_programming/runtime.py— exec / _call_via_providers / _open|_close_model_call_node (where the unified primitive lives)openprogram/providers/callable_model.py— CallableModel adapteropenprogram/agent/agent_loop.py— agent_loop / _execute_tool_callsopenprogram/agent/session.py— AgentSessionopenprogram/agent/dispatcher/__init__.py— process_user_turn / _run_loop_blockingopenprogram/agent/dispatcher/persistence.py— persist_assistant_message (switched to call the unified primitive)openprogram/agent/internals/_turn_lifecycle.py— insert_placeholder / fold_error (delete the duplicated writes)openprogram/store/session/_msg_adapter.py— _node_to_msg (the serialization chokepoint, role restoration)openprogram/agentic_programming/function.py— @agentic_function wrapper