rlmflow records every agent run as a typed trajectory. The trajectory is a
strict alternation of observations and actions:
- Observations are inputs the system received or observed: a user query, an LLM reply, REPL output, a suspension, an error, or a terminal result.
- Actions are work the system did: call the LLM, execute code, or resume a suspended runtime.
Every action is followed by exactly one observation. This makes each transition auditable: the graph says what the engine decided to do and what happened next.
Node
├── ObservationNode
│ ├── UserQuery
│ ├── LLMOutput
│ └── CodeObservation
│ ├── ExecOutput
│ ├── SupervisingOutput
│ ├── ErrorOutput
│ └── DoneOutput
└── ActionNode
├── LLMAction
├── ExecAction
└── ResumeAction
There are nine concrete leaf types under four base classes. Use
isinstance(node, CodeObservation) or is_code_observation(node) for "any
result from running or resuming code."
All nodes share:
type: stable serialized discriminator, such as"llm_output";id: generated node ID;agent_id: owning agent ID, such as"root"or"root.search";seq: per-agent sequence number.
The concrete payloads are:
| Class | type |
Base | Key payload |
|---|---|---|---|
UserQuery |
user_query |
ObservationNode |
content |
LLMAction |
llm_action |
ActionNode |
model |
LLMOutput |
llm_output |
ObservationNode |
reply, code, model, input_tokens, output_tokens |
ExecAction |
exec_action |
ActionNode |
code |
ExecOutput |
exec_output |
CodeObservation |
output, content, resumed_from |
SupervisingOutput |
supervising_output |
CodeObservation |
output, waiting_on, resumed_from |
ErrorOutput |
error_output |
CodeObservation |
error, content, output, resumed_from |
DoneOutput |
done_output |
CodeObservation |
result, content, output, resumed_from |
ResumeAction |
resume_action |
ActionNode |
code, resumed_from |
resumed_from is empty for fresh code execution and populated when the
observation came from resuming a suspended parent after children completed.
LLMOutput.code is the source of truth for executed code. ExecAction.code and
ResumeAction.code are debug/UI echoes of what was run or resumed.
A one-turn successful run looks like this:
UserQuery
-> LLMAction
-> LLMOutput(code="done('answer')")
-> ExecAction
-> DoneOutput(result="answer")
A multi-turn run loops through LLM and exec halves:
UserQuery
-> LLMAction
-> LLMOutput(code="x = compute()")
-> ExecAction
-> ExecOutput(output="...")
-> LLMAction
-> LLMOutput(code="done(x)")
-> ExecAction
-> DoneOutput(result="...")
Errors are observations too. The next LLM turn sees the error message and can recover:
LLMOutput(code="1 / 0")
-> ExecAction
-> ErrorOutput(error="exec_exception", output="ZeroDivisionError: ...")
-> LLMAction
-> LLMOutput(code="done(...)")
If the LLM reply contains no executable code block, the engine records a normal
exec half with ErrorOutput(error="no_code_block").
Agents delegate with:
results = await launch_subagents([
{"name": "search", "query": "Find the evidence", "inputs": {"chunk": chunk}},
])When code awaits launch_subagents(...), the parent runtime suspends and the
engine writes:
ExecAction
-> SupervisingOutput(waiting_on=["root.search"])
The scheduler then runs the child agent. When all children listed in
waiting_on are terminal, the parent becomes runnable again:
SupervisingOutput(waiting_on=["root.search"])
-> ResumeAction(resumed_from=["root.search"])
-> ExecOutput(resumed_from=["root.search"], output="...")
After the resume observation, the parent returns to normal LLM/exec flow.
step(graph) advances each runnable agent by one observation-to-observation
transition. That means one logical reasoning turn usually takes two step
rounds:
- LLM half:
ObservationNode -> LLMAction -> LLMOutput. - Exec half:
LLMOutput -> ExecAction -> CodeObservation.
Resume is also an observation-to-observation transition:
SupervisingOutput -> ResumeAction -> CodeObservation
The pure scheduling logic decides which agents are runnable:
- finished agents do nothing;
- an agent at
LLMOutputruns code next; - an agent at
UserQuery,ExecOutput, orErrorOutputcalls the LLM next; - an agent at
SupervisingOutputresumes only after all children inwaiting_onare terminal; - otherwise, the scheduler descends into unfinished children.
Node sequence numbers are assigned when nodes are appended. Callers populate
payload fields; the engine assigns agent_id, seq, and id.
Saved run directories persist the per-agent trajectory under agents/<agent-id>/,
while the recursive graph manifest links agents through Graph.children. Cross-agent
edges are derived from the recursive graph structure and SupervisingOutput
wait sets; there is no separate edge object to maintain by hand.
Use the predicate helpers from rflow.graph when inspecting traces:
import rflow
for node in graph.all_nodes:
if rflow.is_supervising(node):
print(node.agent_id, "waiting on", node.waiting_on)
elif rflow.is_code_observation(node):
print(node.agent_id, node.type)
elif rflow.is_done(node):
print("result:", node.result)