ADR-0023: Dependency-aware operation-graph execution kernel¶
- Status: Accepted
- Kind: Retrospective
- Area: operations
- Date: 2026-07-09
- Relations: none
Context¶
LionAGI represents a multi-step operation as a directed graph of executable Operation nodes. Construction and execution are intentionally separate, but the execution kernel must turn a generic graph into branch-aware asynchronous work. Six concrete problems shaped the current implementation.
P1 — A graph node needs a serializable request and late-bound conversation state. A builder can know an operation name and parameters before it knows which branch should invoke them. Operation therefore stores the request and optional branch id, while its private branch reference is assigned immediately before invocation (lionagi/operations/node.py).
P2 — Dependency failure or skipping must never strand downstream waiters. Independent nodes should run concurrently, but every predecessor path must settle before dependents decide whether to run. The executor creates one ConcurrencyEvent per operation and releases it from terminal, skipped, completed, failed, cancelled, and defensive exception paths (lionagi/operations/flow.py).
P3 — Conversation isolation must be explicit rather than an accident of scheduling. An explicitly assigned branch is reused. A dependent or context-inheriting operation without one gets a session-owned clone allocated before execution. Some nodes also request conversation-message inheritance from one primary predecessor.
P4 — Data context has two sources with different semantics. Every predecessor result is exposed under an operation-id-derived key, while a shared flow context is copied into each operation and can be updated by successful response mappings. Parallel writes to the same shared key have no declared reducer, so completion order currently decides the winner. One reserved shared key, operator_messages, is control input rather than model context: pending entries are rendered into the instruction and removed from the per-operation context before dispatch.
P5 — A running graph may grow, but growth must reuse the static kernel. Reactive work can arrive through a returned SpawnRequest, a bus emission, explicit injection, or an escalation. Accepted nodes still need dependency waits, capacity limiting, branch allocation, context preparation, pause behavior, and ordinary Branch.get_operation() dispatch.
P6 — Restoring node results is not the same as restoring a running workflow. The kernel can short-circuit operations already in a terminal state and expose restored responses. It does not own a checkpoint format, serialized spawned topology, branch-history reconstruction, or durable event replay. Those omissions are decisive for reactive continuation, not incidental implementation details.
| Concern | Decision |
|---|---|
| Graph request model | D1: Operation is the executable node and OperationGraphBuilder constructs graph shapes without executing them. |
| Static dependency execution | D2: an acyclic executor uses per-node completion events and bounded operation admission. |
| Branch allocation | D3: explicit branches are reused; dependent/context-inheriting nodes receive session-owned clones, with optional primary-message inheritance. |
| Conditions, context, and results | D4: edge conditions gate paths, predecessor results are namespaced by id, response context deep-merges into shared state, and operator messages render once into instructions. |
| Pause and restored terminals | D5: a soft boundary gate pauses new operations; pre-set terminal nodes short-circuit without kernel-owned persistence. |
| Reactive mutation and streaming | D6: reactive execution extends the same kernel with synchronized, capped, cycle-checked injection and typed completion events. |
This ADR deliberately does not decide:
- Checkpoint storage, retention, schema migration, or resume degradation policy; those are owned by the scheduling-control-plane ADR on flow checkpoint and resume semantics.
- How a CLI planner maps roles, workspaces, or persisted definitions into initial graph nodes; the kernel receives an already constructed
Graph[Operation]. - The semantics of individual branch verbs; ADR-0021 and ADR-0022 own operation dispatch and composed execution.
- Distributed graph scheduling, cross-process queues, or remote workers. The executor is an in-process async kernel.
- Deterministic conflict resolution for shared context. The current completion-timing behavior is recorded as shipped truth and retained as a delta, not specified as an ideal reducer.
Decision¶
D1 — Operation is the executable node; the builder only constructs graph state¶
The node contract is:
BranchOperations = Literal[
"chat",
"operate",
"communicate",
"parse",
"ReAct",
"select",
"interpret",
"act",
"ReActStream",
]
class Operation(Node, Event):
operation: BranchOperations | str
parameters: dict[str, Any] | BaseModel = Field(
default_factory=dict,
exclude=True,
)
_branch: Any = PrivateAttr(default=None)
@property
def branch_id(self) -> UUID | None: ...
@property
def graph_id(self) -> str | None: ...
@property
def request(self) -> dict: ...
@property
def response(self): ...
async def _invoke(self): ...
def create_operation(
operation: BranchOperations | str,
parameters: dict[str, Any] | BaseModel = None,
**kwargs,
): ...
parameters are excluded from the ordinary Pydantic field dump, but request returns a mapping: it uses model_dump() or legacy dict() for a model and returns {} for any other non-mapping value. response projects execution.response after event invocation.
The builder's public construction surface is:
class ExpansionStrategy(Enum):
CONCURRENT = "concurrent"
SEQUENTIAL = "sequential"
SEQUENTIAL_CONCURRENT_CHUNK = "sequential_concurrent_chunk"
CONCURRENT_SEQUENTIAL_CHUNK = "concurrent_sequential_chunk"
class OperationGraphBuilder:
def __init__(self, name: str = "DynamicGraph"): ...
def add_operation(
self,
operation: str,
node_id: str | None = None,
depends_on: list[str] | None = None,
inherit_context: bool = False,
branch=None,
**parameters,
) -> str: ...
def expand_from_result(
self,
items: list[Any],
source_node_id: str,
operation: str,
strategy: ExpansionStrategy = ExpansionStrategy.CONCURRENT,
inherit_context: bool = False,
chain_context: bool = False,
**shared_params,
) -> list[str]: ...
def add_aggregation(
self,
operation: str,
node_id: str | None = None,
source_node_ids: list[str] | None = None,
inherit_context: bool = False,
inherit_from_source: int = 0,
branch=None,
**parameters,
) -> str: ...
def add_conditional_branch(
self,
condition_check_op: str,
true_op: str,
false_op: str | None = None,
**check_params,
) -> dict[str, str]: ...
Exact node and builder semantics:
Operation._invoke()requires_branchto be set, resolves the name throughBranch.get_operation(), stores the actual branch id, and awaits the callable. A missing branch raisesExecutionError; a missing operation raisesOperationError.ReActStreamis the only special operation name: its async generator is fully consumed into a list so the event has one terminal response.node_idon builder methods is a human reference stored asmetadata["reference_id"]; it does not replace the generated element UUID.add_operation()addsdepends_onedges only for dependency ids already known to that builder. With no explicit dependency, it links every current head to the new node using a"sequential"label. A supplied branch is normalized throughID.get_id().inherit_context=Truewith dependencies records oneprimary_dependency, always the firstdepends_onentry. This metadata controls conversation-message inheritance under D3.expand_from_result()raisesOperationErrorwhen the source is unknown. Model items are dumped into parameters; other items becomeitem_indexand stringifieditem. Every child records the source and strategy and receives an expansion edge from the source.- The expansion strategy is stored in parameters/metadata and edge labels. In this builder method, it does not itself install inter-child dependency edges;
chain_contextchanges the primary message-inheritance source only for sequential strategy children after the first. add_aggregation()uses explicit sources or current heads, raises when neither exists, stores source ids and count in parameters, and adds an edge from every source.inherit_from_sourceis clamped to the final source index.add_conditional_branch()creates a check node and labeledif_true/if_falseoutgoing edges. Labels alone are descriptive; executable gating requires anEdgeConditionconsumed by D4.- Builder methods mutate a generic
Graph;get_graph()returns it. No builder method callsflow(), assigns runtime branches beyond metadata, or invokes an operation.
Why this way. Late branch binding lets one graph definition execute inside a session without serializing a live branch into each node. Keeping construction separate makes graph topology inspectable and testable before effects occur. The open | str operation name is required for session-registered verbs and for built-ins not enumerated by the current literal.
D2 — Static execution is acyclic, dependency-aware, and capacity-bounded¶
The base executor contract is:
UNLIMITED_CONCURRENCY = int(os.environ.get("LIONAGI_MAX_CONCURRENCY", "10000"))
class DependencyAwareExecutor:
def __init__(
self,
session: Session,
graph: Graph,
context: dict[str, Any] | None = None,
max_concurrent: int = 5,
verbose: bool = False,
default_branch: Branch = None,
alcall_params: AlcallParams | None = None,
executor_ref: dict[str, Any] | None = None,
on_branch_created: Callable[[Any], None] | None = None,
): ...
async def execute(self) -> dict[str, Any]: ...
The session facade defaults to max_concurrent=5; the module-level flow() accepts None, which the executor interprets as UNLIMITED_CONCURRENCY. The name means “use a high configured bound,” not literal infinity: its inherited environment default is 10,000. parallel=False at the flow() boundary forces max_concurrent=1. The code records no benchmark or dependency-capacity rationale for 5 or 10,000; callers are expected to select the bound their operations can absorb.
Exact scheduling semantics:
execute()rejects a cyclic graph withOperationErrorbefore branch allocation or invocation.- Every edge carrying a condition must hold an
EdgeConditionwith anapplymethod. Invalid types or missing application behavior fail validation before work starts. - Construction creates one
ConcurrencyEventfor everyOperation. A node already markedCOMPLETEDhas its event set and its response copied into results immediately. - Branches are preallocated under D3 before tasks are submitted.
- All operation coroutines are submitted through the selected
AlcallParams; a sharedCapacityLimiterencloses preparation and invocation. Capacity therefore bounds model/tool work and the immediately preceding context/branch materialization. - An operation first evaluates incoming paths, then waits for all declared predecessors and aggregation sources, then crosses the pause gate, then acquires capacity.
- A failed
Event.invoke()is represented by the operation'sFAILEDstatus and becomes{"error": str(operation.execution.error)}inresults. An unexpected executor-level exception is defensively converted to the same mapping when no result exists. - Cancellation, keyboard interrupt, and system exit set the completion event and re-raise. Every ordinary path also sets the event in
finally, including skip, failure, and defensive exception. Downstream waiters therefore do not hang solely because an upstream node did not complete successfully. - Progress callbacks receive
queued,started,completed, orfailed. A condition-skipped node currently reportsfailedto that callback even though its event/flow status isSKIPPED; the typedFlowEventin D6 distinguishes this ordinary skip from an invoked operation failure. It is not fully authoritative for defensive executor exceptions, as D4 and D6 record.
Why this way. Per-node events express dependency settlement independently of task submission order. A sequential traversal would leave independent work idle; unbounded submission without a limiter would transfer overload to providers and tools. The limiter surrounds the full operation boundary so capacity represents active operations, not only the final await.
D3 — Branch reuse, cloning, and primary-message inheritance are explicit¶
Before execution, _preallocate_all_branches() scans Operation nodes:
explicit branch_id resolves in Session.branches
└─ reuse that exact Branch
no branch_id + (has predecessors or inherit_context)
└─ clone Session.default_branch
├─ Session.include_branches(clone)
├─ call on_branch_created(clone), when supplied
└─ optionally mark pending primary-message inheritance
no branch_id + root/no inheritance
└─ resolve to executor default branch or Session.default_branch at invocation
Exact branch semantics:
- Explicit
branch_idtakes precedence. If lookup fails, the executor logs the miss and leaves the node for normal fallback rather than failing preallocation. - Clones are made from
session.default_branchwithsender=session.id, stored inoperation_branches, and included through the full session inclusion path. That path establishes ownership, observer, shared registry, hooks, memory, and exchange registration. - The optional synchronous
on_branch_createdcallback runs for each preallocated clone after session inclusion. It is a wiring seam; its return value does not affect execution. inherit_contexthere means conversation-message inheritance, not D4's data mapping. The clone is marked withpending_context_inheritanceand the configuredprimary_dependency.- When the operation is prepared after dependencies settle, the executor finds the primary predecessor's branch, clears the clone's message pile, and copies cloned predecessor messages. It then clears the pending flag. Only one primary dependency supplies conversation history.
- A node that was preallocated but has no usable primary result keeps its ordinary cloned history.
- Reactive children do not use this preallocation path. D6 clones from an explicit child branch, the emitter branch for dependent spawns, or the session default.
Why this way. Reusing an explicit branch allows intentional state accumulation. Cloning dependent work prevents parallel operations from racing on one conversation while still letting a caller choose one primary history to inherit. Preallocation avoids acquiring the session branch lock in the operation hot path.
D4 — Incoming paths gate execution; context is materialized and deep-merged¶
For each operation, the condition input is:
{
"result": predecessor_result_as_mapping_or_scalar,
"context": executor.context.content,
}
The per-operation input context is assembled as:
{
"<predecessor-uuid>_result": predecessor_result,
# ...one entry per settled, non-skipped predecessor with a result
# then shared flow-context keys
}
The reserved live-steer entry shape and rendered instruction block are:
{
"operator_messages": [
{
"ts": float | str,
"text": str,
"rendered_into_op": str | None, # runtime breadcrumb
}
]
}
[OPERATOR STEER]
A human operator sent these live corrections while this flow is running.
Attend to them before continuing. Most recent last.
- <UTC timestamp>: <text>
[/OPERATOR STEER]
<original instruction>
The static result envelope is:
{
"completed_operations": list[UUID],
"operation_results": dict[UUID, Any],
"final_context": dict[str, Any],
"skipped_operations": list[UUID],
}
Exact condition, context, and result semantics:
- A node with no incoming edges has a valid path. With incoming edges, an edge whose predecessor was skipped is ignored; the node runs when at least one remaining edge condition returns true. An edge with no condition returns true. If no incoming path passes, the node is marked
SKIPPED. - Condition checks wait on the predecessor's completion event before reading its result. A failed predecessor contributes its error mapping and may still satisfy an unconditional edge; failure does not automatically skip all dependents.
_wait_for_dependencies()then waits for every graph predecessor. Aggregations also wait for each UUID string named inaggregation_sourceswhen it matches a completion-event id.- Predecessor results are converted recursively to mappings except for string, integer, float, and Boolean scalars. Skipped predecessors contribute no result key.
- If an operation's existing
contextis a mapping, predecessor entries update it. If it is another value, that value is preserved underoriginal_contextbefore predecessor entries are added. - Shared flow context is applied after predecessor context. It updates a mapping in place or is combined with
original_contextfor a non-mapping. A colliding shared key therefore overrides a predecessor-derived key at preparation time. _render_operator_messages()always removesoperator_messagesfrom the operation's context; the raw queue is never passed to the model as JSON. Dictionary entries without a truthyrendered_into_opare formatted in list order, prepended to the instruction, and mutated with the current operation id as their breadcrumb. Non-dictionary entries and already-rendered entries do not render. Numeric timestamps become UTCYYYY-MM-DDTHH:MM:SSZ; unparseable timestamps fall back tostr(ts).- A second last-chance check runs immediately before
Operation.invoke(). If new unrendered entries reached the canonical shared queue after context preparation, it reattaches the queue long enough to render and pop it. Breadcrumb mutation is shared across the shallow context copies, so the same entry is consumed once across later operations (lionagi/operations/flow.py). - When a successful operation response is a mapping containing
context,deep_update()merges that value into the single sharedNote. Nested non-conflicting keys survive. - The response-side
contextvalue is not type-validated beforedeep_update(). A non-mapping raises after the operation response has already been stored. The defensive catch leaves that stored response and the operation's completed event status intact, does not apply the invalid context, and can omit the ordinary completed progress callback. - Parallel operations can finish in either order. When their returned context mappings collide, there is no namespace or reducer; whichever deep merge executes later wins for that key.
- An exception raised while checking a condition is caught as an executor-level error mapping and releases the completion event, but the node's
EventStatusis not explicitly set toFAILED. Static results therefore include the error entry as settled, and D6's status projection can label this defensive pathcompleted. Invoked operation failures do setFAILEDand project correctly. completed_operationsis computed as every id inresultsthat is not skipped. Because failed operations also receive error entries inresults, the name includes failures. It means “settled with a result entry and not skipped,” notEventStatus.COMPLETED.operation_resultsis the authoritative per-id value/error mapping.skipped_operationsis disjoint fromcompleted_operations;_validate_execution_results()raises if the lists overlap.
Why this way. Namespacing predecessor results by UUID prevents ordinary fan-in collisions and lets an aggregation inspect every source. A shared context supports flow-wide accumulation without requiring each node to know all predecessors. The current ungoverned collision rule is simple but nondeterministic; D4 records it honestly rather than claiming deterministic reduction. Rendering operator corrections into the instruction gives them a dedicated, consume-once channel instead of mixing control directives into ordinary model context.
D5 — Pause is soft at operation boundaries; restored terminal nodes short-circuit¶
The control methods are synchronous and idempotent:
class DependencyAwareExecutor:
def pause(self) -> None: ...
def resume(self) -> None: ...
Exact control and restoration semantics:
pause()installs a new unsetConcurrencyEventonly when no pause exists. Repeated calls while paused keep the same gate.resume()sets the current gate and clears the reference. Calling it while not paused is a no-op. A laterpause()creates a fresh event.- Operations wait at the gate after condition/dependency waits and before acquiring the capacity limiter. Operations already past that boundary continue; pause does not cancel or interrupt them.
- A waiting operation emits a best-effort
NodePausedsignal. Signal construction/emission errors cannot break the pause path. - An operation whose status is in
Event._TERMINAL_STATUSESreturns without invocation. When it has a response and no result entry, that response is restored intoresults; its completion event is set. - Constructor-time special handling preloads only
COMPLETEDresponses, but execution-time short-circuiting applies to all terminal statuses. - The kernel does not load terminal state from disk, validate a checkpoint version, reconstruct a branch, or rebuild topology. A caller must restore the graph nodes and responses before calling the executor.
Why this way. A boundary pause has a clear safety property: admitted operations settle, new ones wait. Interrupting active model/tool calls would require per-verb cancellation and compensation semantics the graph kernel does not own. Terminal short-circuiting enables caller-managed replay without coupling a reusable scheduler to one durability format.
D6 — Reactive execution synchronizes and caps graph growth, then streams typed settlements¶
The default spawn payload is:
class SpawnRequest(BaseModel):
instruction: str
assignee: str | None = None
operation: Literal["operate", "chat", "communicate", "ReAct"] = "operate"
independent: bool = False
reason: str | None = None
The reactive and streaming contracts are:
@dataclass(slots=True)
class FlowEvent:
operation_id: str
name: str
status: str # "completed" | "failed" | "skipped"
result: Any
spawned: bool = False
@property
def ok(self) -> bool:
return self.status == "completed"
class ReactiveExecutor(DependencyAwareExecutor):
def __init__(
self,
*args: Any,
spawn_type: type | None = None,
node_builder: Any = None,
max_spawn: int = 50,
spawn_branch_setup: Callable[[Operation, Any], None] | None = None,
**kwargs: Any,
): ...
async def execute(self) -> dict[str, Any]: ...
async def execute_stream(self): ...
def inject(
self,
operation: Operation,
*,
after: Operation | str | None = None,
independent: bool = False,
) -> bool: ...
Reactive execution returns the D4 envelope plus:
{
"spawned_operations": int,
"escalated_operations": list[UUID],
"dropped_spawns": [
{
"reason": "builder_error" | "null_child" | "cycle"
| "max_spawn_exceeded" | "duplicate",
"assignee": str | None,
"emitter_id": UUID | None,
# optional: "op_id", "error"
}
],
}
Exact reactive semantics:
- The executor reuses D2's acyclicity, edge validation, preallocation, capacity limiter, operation method, pause gate, context logic, and result storage. Initial and accepted nodes run in one task group.
- While running, it subscribes through the session's public observer to the configured spawn type and
EscalationRequest. It unsubscribes infinally. - Spawn requests are discovered both from bus delivery and by recursively scanning settled results through lists, tuples, Pydantic model fields, and mapping values to depth 4. The bound prevents an unbounded walk through arbitrary returned structures; the code records no rationale for exactly four nested levels.
- The same request object is de-duplicated by Python object identity. Its second sighting is recorded as a
duplicatedropped spawn; semantically identical but distinct objects are not de-duplicated. - The default node builder creates the requested operation (or
operate) with only the request instruction. A custom builder exception becomesbuilder_error; aNonechild becomesnull_child. Neither aborts the rest of the graph. - A recorded
builder_errorkeeps at most 500 characters of the exception string. The best-effortNodeSpawnedsignal keeps at most 512 characters of the child instruction. These caps bound observability payloads only; they do not alter the exception used for logging or the instruction stored on the operation. No rationale for the exact 500/512 split is recorded. - Acceptance is guarded by a threading lock. The default cap is 50 accepted injected nodes; initial nodes do not count. At the cap, the child is dropped with
max_spawn_exceeded. The code records no workload measurement for exactly 50; it is an inherited runaway guard and is caller-configurable. - A new child is added to the graph and receives a completion event. A dependent spawn adds an edge from emitter to child; an independent spawn does not. If that mutation creates a cycle, the edge and newly added node/event are removed before returning false.
- An injected
Operationwhose UUID is already present is not rejected as an operation duplicate. It can receive another spawn edge, increments the spawn count, is marked spawned, and is scheduled again. Because it is notnewly_added, it receives neither a new completion event nor branch assignment norparent_idmetadata. Request-identity de-duplication therefore does not make explicit operation reinjection idempotent. - Accepted children increment the spawn count, are marked as spawned, and are scheduled through the same
_run_tracked()path. Newly added dependent children storeparent_idmetadata. - A newly added reactive child branch is always a clone. The base is the child's explicit branch when valid, otherwise the emitter's branch for a dependent spawn, otherwise the session default. The clone is session-included before
spawn_branch_setup(child, clone)runs. on_branch_createdfrom D3 is not called for reactive clones. The separatespawn_branch_setupseam is invoked, which leaves general persistence-hook parity unresolved.inject()outside a running executor returns false. Accepted explicit injections follow the same cap, edge, cycle, branch, and scheduling rules.- An escalation with route
"higher_tier"and an emitter creates an independent child of the same operation, copies parameters, and replaces the instruction with an escalation notice. Other routes record/emit the escalation without scheduling a child.
Exact streaming semantics:
_run_tracked()emits oneFlowEventafter each node settles. Status isskippedfor the skip set,failedforEventStatus.FAILED, andcompletedotherwise. It discriminates ordinary invoked failures, but an executor-level exception that stored an error without setting the event status still projects ascompleted.execute_stream()usesanyio.create_memory_object_stream(math.inf): the completion-event channel is unbounded and provides no backpressure. No recorded rationale establishes that unbound; it follows the assumption that completion events are small and short-lived.- An asyncio task created with
asyncio.ensure_future()owns the task group while the async generator drains the channel. This avoids spanning an AnyIO task-group context acrossyieldand makes the current streaming surface asyncio-specific. - Normal channel close is followed by awaiting the driver so driver exceptions propagate. If the consumer breaks early, the generator cancels and awaits the driver so remaining work does not keep running invisibly.
Why this way. A second scheduler would duplicate the most failure-sensitive parts of static execution. Synchronized mutation plus an immediate acyclicity check preserves the graph invariant while a hard cap prevents unbounded self-expansion. The typed event makes completion-order streaming explicit and improves on the legacy aggregate result name, while retaining the defensive-error gap documented above.
Consequences¶
- Fixed and reactive graphs share dependency ordering, capacity, pause, branch, context, and dispatch semantics.
- Every upstream path releases its event, so a failed or skipped predecessor cannot deadlock a dependent solely by failing to signal settlement.
- Explicit branch reuse supports intentional shared history; default cloning isolates dependent work but increases session branch count and copying cost.
- Incoming edge conditions are OR paths, while dependency waiting is all-predecessor settlement. Contributors must not confuse “one valid path” with “wait for one predecessor.”
- The shared flow context is convenient but not deterministic under colliding parallel writes. Consumers needing reproducibility must avoid collisions today.
- Live operator messages are rendered once as an instruction prefix and withheld from raw model context. The shared queue is mutated with consumption breadcrumbs, so callers treating their input context as immutable will observe otherwise.
completed_operationsis a compatibility hazard because failures appear in it. Consumers needing truth must inspect operation status and results together.FlowEvent.statusimproves ordinary failure reporting but still labels defensive executor errors completed when no event failure was set.- Reactive mutation is bounded and cycle-safe, but object-identity de-duplication does not provide durable or semantic idempotency across reconstruction, and reinjecting an existing operation UUID can schedule that node again without a fresh branch or completion event.
- Streaming offers immediate settlement events but is currently tied to asyncio and an unbounded in-memory channel.
- Pre-marked nodes enable caller-owned static resume, but reactive continuation remains unsafe without persisted spawned topology, edges, branches, messages, requests, and shared context.
- Reversing D1 or D2 is high-cost because graph definitions and all flow callers depend on them. Changing D4 collision policy is medium-cost and requires migration guidance for consumers. Replacing D6 internals is feasible if the result/event contracts and static-kernel reuse remain.
- Focused static, reactive, stream, parallelism, pause, and node tests support
τ ≈ 0.9; they do not establish crash consistency or portability to every async backend.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Replace completed_operations with explicit completed, failed, and skipped collections, retaining a deprecated compatibility alias whose settled-not-skipped meaning is documented; classify defensive executor exceptions consistently in both the aggregate and FlowEvent. | M | (filled at issue-open time) |
| 2 | Define deterministic shared-context semantics by namespacing node outputs by default and requiring an explicit reducer for colliding shared keys; add parallel collision tests. | M | (filled at issue-open time) |
| 3 | Pass on_branch_created into reactive execution and invoke it for every injected clone; add identical persistence-hook coverage for preallocated and spawned branches. | S | (filled at issue-open time) |
| 4 | Extract reactive graph mutation and streaming-driver concerns behind internal collaborators while retaining one public executor contract; document the required async backend. | M | (filled at issue-open time) |
| 5 | Specify durable reactive continuation with persisted spawned topology, parent edges, operation requests, branch reconstruction, context, and conversation-history policy; keep the current fail-closed refusal until all fields can be restored. | L | #2015 |
| 6 | Validate that an operation response's context is a mapping before deep merge; on mismatch, produce one explicit failed status/result and add a regression test for progress and streaming status. | S | #2023 |
| 7 | Define existing-operation UUID injection as reject, no-op, or intentional rerun; enforce that choice before graph mutation and test branch, completion-event, and spawn-count behavior. | S | (filled at issue-open time) |
Alternatives considered¶
Execute the graph as a sequential traversal¶
This would make result order, context writes, and error propagation easy to reason about. It lost because independent nodes would never overlap, discarding the principal value of a DAG. It also would not remove the need for condition semantics or branch isolation.
Schedule dependents by recursively awaiting predecessor tasks¶
This would avoid an explicit event table. It lost because terminal/restored/skipped nodes and reactively injected nodes do not all originate from one stable task tree. Per-node completion events provide a uniform settlement primitive and can be pre-set for restored work.
Use one shared branch for every node¶
This would avoid cloning and make all prior messages visible. It lost because independent nodes would mutate one conversation concurrently, producing nondeterministic history and provider-session state. The explicit branch_id path remains available when shared accumulation is intentional.
Clone a branch for every node, including independent roots with no metadata¶
This would maximize isolation and make allocation uniform. It lost because root nodes commonly use the caller's intended default conversation; cloning every root changes history semantics and creates unnecessary branches. The current rule clones only when dependency/history isolation requires it.
Replace shared context with immutable per-edge payloads only¶
This would eliminate completion-order collisions and make dataflow fully explicit. It lost in the shipped architecture because callers rely on a flow-wide workspace and operations can contribute context without knowing every downstream edge. Deterministic namespacing/reducers remain the smaller corrective path.
Pass operator_messages through as ordinary context¶
This would avoid special-key handling and mutation of the caller's queue. It lost because live corrections are control directives, not background data: leaving the raw list in context makes consumption ambiguous and asks each operation to notice the convention. The shipped renderer turns pending entries into a visible instruction prefix, removes the raw key, and records one consumption breadcrumb.
Treat any predecessor failure as automatic downstream skip¶
This would make failure propagation simple. It lost because some operations can consume error results, unconditional alternate paths may remain valid, and edge conditions are the explicit place to decide viability. Failed predecessors therefore settle and expose an error mapping.
Build a separate reactive scheduler¶
This would isolate mutation and streaming complexity from the static executor. It lost because the new scheduler would need to duplicate dependency events, branch allocation, capacity, pause, conditions, context, and operation dispatch. Subclassing preserves one execution kernel while the delta acknowledges that internal collaborators would improve maintainability.
Permit unlimited reactive spawning¶
This would avoid dropping legitimate recursive work. It lost because a self-returning SpawnRequest can grow without bound. The configurable cap provides an explicit failure record instead of exhausting memory or provider capacity.
Treat an existing operation UUID as an idempotent injected no-op¶
This would make explicit reinjection safe for callers retrying delivery. It did not ship: the acceptance path tests only whether a node must be added, then still counts and schedules an existing node. The current behavior is recorded rather than called idempotent; delta 7 requires an explicit choice before consumers rely on conflict handling.
Persist checkpoints inside flow.py¶
This would make resume appear self-contained. It lost because storage format, topology versioning, branch serialization, partial-write policy, and degradation behavior are cross-layer concerns. The kernel supports restored terminal nodes but does not pretend that is a complete durable replay.
Use a task group directly across async-generator yields¶
This would avoid the asyncio driver task in streaming mode. It lost because AnyIO task-group context ownership cannot safely span generator yields. The current detached driver is explicit, though it creates the asyncio-specific portability delta.
Notes¶
Primary implementation anchors are lionagi/operations/node.py, lionagi/operations/builder.py, lionagi/operations/flow.py, lionagi/protocols/graph/edge.py, lionagi/casts/emission.py, and lionagi/session/session.py. Focused behavioral anchors live in tests/operations/test_operation_node.py, tests/operations/test_builder_extended.py, tests/operations/test_flow.py, tests/operations/test_flow_parallelism.py, tests/operations/test_flow_pause.py, tests/operations/test_flow_stream.py, and tests/operations/test_reactive_flow.py, plus operator-message coverage in tests/operations/test_flow_operator_steer.py.