v3.5.0 — Loop & Graph¶
Headline: AgentEnsemble gains two new top-level workflow constructs —
Loopfor bounded iteration andGraphfor state-machine flows with arbitrary back-edges. Full observability, dedicated trace structures, viz support for both, and a built-in dashboard.
TL;DR¶
Loop— declarative bounded iteration: writer/critic reflection, retry-until-valid, multi-turn debate. Three output projection modes, three max-iterations actions, three memory modes (includingWINDOW(n)for prompt-size control), automatic revision-feedback injection, executes concurrently with independent tasks underPARALLELworkflow.Graph— declarative state machine: tool routers, selective-feedback edges, conditional routing with arbitrary back-edges. The piece AE was missing for genuine agent-flow expressivity.- No breaking changes. Both constructs are additive. Existing ensembles continue to work unchanged.
- Full observability: per-iteration / per-step callbacks, dedicated trace structures, DAG schema 1.3 with viz support for both constructs, live dashboard updates.
- 4400+ Java tests + 379 viz tests passing,
mkdocs build --strictclean.
What's new¶
Loop — bounded iteration¶
Loop reflection = Loop.builder()
.name("reflection")
.task(writeTask)
.task(critiqueTask)
.until(ctx -> ctx.lastBodyOutput().getRaw().contains("APPROVED"))
.maxIterations(5)
.build();
Ensemble.builder().task(researchTask).loop(reflection).build().run();
Capabilities:
- Output projection (
LoopOutputMode):LAST_ITERATION(default),FINAL_TASK_ONLY,ALL_ITERATIONS. - Termination on cap (
MaxIterationsAction):RETURN_LAST(default),THROW(MaxLoopIterationsExceededException),RETURN_WITH_FLAG(sets a flag onEnsembleOutput.wasLoopTerminatedByMaxIterations(name)). - Memory modes (
LoopMemoryMode):ACCUMULATE(default),FRESH_PER_ITERATION(via newMemoryStore.clear(scope)),WINDOW(n)(via existing eviction policy). - Automatic revision-feedback injection on iteration ≥ 2 — the body's first task is
rebuilt via
Task.withRevisionFeedback(...)so the LLM sees the prior output and an auto-generated revision-instructions prompt section. - True PARALLEL+Loop concurrency — loops execute as first-class nodes in the parallel
DAG via the shadow-task pattern. Loops with no outer deps run alongside other root
tasks;
Loop.context(taskA)makes a loop wait for upstream completion. Multiple loops with no shared deps run concurrently. - Per-iteration observability —
EnsembleListener.onLoopIterationCompletedevent with iteration number, max iterations, body outputs, and step duration. Builder convenience:Ensemble.builder().onLoopIterationCompleted(handler). - Side channels on
EnsembleOutput: getLoopHistory(name)— full per-iteration outputs by body-task name.getLoopTerminationReason(name)—"predicate"or"maxIterations".wasLoopTerminatedByMaxIterations(name)— flag forRETURN_WITH_FLAG.LoopTraceonExecutionTrace.loopTraces— name, iterations run, termination reason, per-iteration body-task names.- Viz support —
DagModelschema 1.2 addsnodeType: "loop"super-nodes withloopMaxIterationsand an expandableloopBodysub-DAG.
See the Loops guide and Loops examples.
Graph — state machine¶
Graph router = Graph.builder()
.name("agent")
.state("analyze", analyzeTask)
.state("toolA", toolATask)
.state("toolB", toolBTask)
.start("analyze")
.edge("analyze", "toolA", ctx -> ctx.lastOutput().getRaw().contains("USE_A"))
.edge("analyze", "toolB", ctx -> ctx.lastOutput().getRaw().contains("USE_B"))
.edge("analyze", Graph.END) // unconditional fallback
.edge("toolA", "analyze") // back-edge
.edge("toolB", "analyze")
.maxSteps(20)
.build();
Ensemble.builder().graph(router).build().run();
Capabilities:
- Conditional + unconditional edges with first-match-wins semantics in declaration
order. Per-edge
GraphPredicatelambda ornullpredicate for unconditional fallbacks. Optional human-readableconditionDescriptionfor visualisation. - Arbitrary back-edges — a state can be visited many times; the executor walks a true state machine, not a DAG.
- Termination via
Graph.ENDsentinel target on any edge, or hittingmaxSteps(default 50).MaxStepsAction.RETURN_LAST | THROW | RETURN_WITH_FLAGmirror Loop's semantics. - State revisits with auto-feedback — on visit ≥ 2 the state Task is rebuilt via
Task.withRevisionFeedback(...)so the LLM sees the prior visit's output and a visit number. Per-state suppression via.stateNoFeedback("router", routerTask)(useful for stateless router states that should not be biased by prior visits). Global suppression via.injectFeedbackOnRevisit(false). - Per-step observability —
EnsembleListener.onGraphStateCompletedevent with state name, step number, max steps, output, routed-to next state, and step duration. Builder convenience:Ensemble.builder().onGraphStateCompleted(handler). - Side channels on
EnsembleOutput: getGraphHistory()— full per-stepGraphSteprecords (state, step number, output, next state) in execution order. Visits to the same state appear multiple times.getGraphTerminationReason()—"terminal"or"maxSteps".wasGraphTerminatedByMaxSteps()— flag forRETURN_WITH_FLAG.GraphTraceonExecutionTrace.graphTrace— name, start state, termination reason, step count, per-step state name + step number.- Viz support —
DagModelschema 1.3 addsmode: "graph"for graph ensembles, withnodeType: "graph-state"for state nodes and"graph-end"for the terminal cap. Theagentensemble-vizdashboard renders graphs top-to-bottom (vs left-to-right for legacy DAGs) with conditional edge labels (dashed for unconditional) and post-execution fired/unfired styling. NewDagExporter.build(graph, graphTrace)overload for post-execution overlays. - Build-time validation rejects: empty body, unknown edge endpoints, edges from
Graph.END, states with no outgoing edges (would deadlock), reservedGraph.ENDstate name, missing or unknownstart,maxSteps < 1. - Mutual exclusion with
tasks,loops,phases— a graph ensemble is exclusive, enforced byEnsembleValidator.
See the Graphs guide and Graphs examples.
Shared infrastructure¶
Both constructs share a refactor that's also useful in isolation:
WorkflowNodeinterface — a marker interface implemented byTask,Loop, andGraph. LetsEnsemble.builder()accept a heterogeneous mix via.task(...)/.loop(...)/.graph(...)and lets the parallel scheduler treat loops as first-class nodes in the dependency graph.MemoryStore.clear(scope)— added to supportLoopMemoryMode.FRESH_PER_ITERATION.InMemoryStoreremoves the scope;EmbeddingMemoryStorethrows an actionableUnsupportedOperationExceptiondirecting users to either switch the loop's scopes toinMemory()or useACCUMULATEmode (vector stores generally cannot delete by metadata filter).Task.withRevisionFeedback(feedback, priorOutput, attempt)— the existing primitive used byPhaseReviewis now also used by bothLoopandGraphfor visit / iteration revision-feedback injection. No new API surface; consistent semantics across all three constructs.
Migration¶
No migration required. All existing ensembles, agents, tasks, phases, loops (none in v3.x), and graphs (none in v3.x) continue to work unchanged. The new constructs are additive.
If you were previously simulating reflection loops with PhaseReview.retryPredecessor
or hiding state-machine logic inside a single agent's ReAct loop, you can now express
those patterns directly with Loop and Graph. Both old approaches continue to work;
migrate at your own pace.
Performance notes¶
Loopbody execution reusesSequentialWorkflowExecutor.executeSeeded(...)per iteration — no new allocation hot paths.- PARALLEL+Loop concurrency uses one virtual thread per loop (the shadow task) plus one per body iteration (sequential within the iteration). For an ensemble with N independent loops, you get N concurrent virtual threads — the same scaling as N independent root tasks. Negligible coordinator overhead.
Graphis single-threaded by design (one current state at a time). Memory overhead isO(steps)for the per-step history list plusO(states)for the visit-count map. No additional thread or synchronization cost vs sequential task execution.- Trace and DAG export add ~1KB per loop or graph and ~50–200 bytes per iteration / step. Negligible compared to LLM payloads.
Known limitations¶
- Nested loops rejected at build time. The combinatorial iteration-cap multiplication is a real footgun; we reserve the right to lift this if real demand surfaces. Nested graphs (a Graph state whose Task is itself a Graph) are also out of scope in v1.
- Graph + Loop / Phase mixing is rejected — graph ensembles are exclusive. Lift later if needed.
Workflow.HIERARCHICALrejects both Loops and Graphs. UseSEQUENTIAL,PARALLEL, or no explicit workflow setting (the default).Task.contextstill accepts onlyTaskinstances (notLooporGraph). To put a Task strictly after a Loop / Graph, place the post-work as the final body task or state, or use Phases.- Parallel branches inside a Graph (fan-out /
Send-style) — single-threaded executor in v1; planned follow-up. - Cross-run checkpointing of graph state — separate from this PR; feeds into the existing memory / durable-transport story.
EmbeddingMemoryStoredoes not supportclear(scope)— vector stores generally cannot delete by metadata filter. SelectingLoopMemoryMode.FRESH_PER_ITERATIONagainst an embedding store throws an actionableUnsupportedOperationExceptionpointing to eitherMemoryStore.inMemory()orLoopMemoryMode.ACCUMULATE.
What ships in this release¶
New types¶
net.agentensemble.workflow.WorkflowNode (marker interface).
net.agentensemble.workflow.loop.*:
- Loop, LoopExecutor, LoopExecutionResult
- LoopPredicate, LoopIterationContext
- MaxIterationsAction, LoopOutputMode, LoopMemoryMode
net.agentensemble.workflow.graph.*:
- Graph, GraphExecutor, GraphExecutionResult, GraphStep
- GraphEdge, GraphPredicate, GraphRoutingContext
- MaxStepsAction
net.agentensemble.callback.*:
- LoopIterationCompletedEvent
- GraphStateCompletedEvent
net.agentensemble.exception.*:
- MaxLoopIterationsExceededException
- MaxGraphStepsExceededException
- GraphNoEdgeMatchedException
net.agentensemble.trace.*:
- LoopTrace
- GraphTrace (with nested GraphStepTrace)
net.agentensemble.devtools.dag.DagGraphEdge.
Modified types¶
Task— implementsWorkflowNode; otherwise unchanged.Ensemble.Builder— gains.loop(Loop),.graph(Graph),.onLoopIterationCompleted(...),.onGraphStateCompleted(...).EnsembleOutput— addsgetLoopHistory(name),getLoopTerminationReason(name),wasLoopTerminatedByMaxIterations(name),getGraphHistory(),getGraphTerminationReason(),wasGraphTerminatedByMaxSteps().EnsembleListener— adds defaultonLoopIterationCompletedandonGraphStateCompletedmethods.MemoryStore— adds abstractclear(scope)method (impls inInMemoryStoreandEmbeddingMemoryStore).WorkflowExecutor— adds defaultexecuteNodes(List<WorkflowNode>, ...)(overridden bySequentialWorkflowExecutorandParallelWorkflowExecutorto handle loops).DagModel— schema 1.3; addsmode,graphEdges,graphStartStateId,graphTerminationReason,graphStepsRun.DagTaskNode— addsnodeTypevalues"loop","graph-state","graph-end"; addsloopMaxIterationsandloopBodyfor loop super-nodes.ExecutionTrace— addsloopTracesandgraphTrace.
Examples¶
./gradlew :agentensemble-examples:runLoopReflection # writer + critic loop
./gradlew :agentensemble-examples:runLoopRetryUntilValid # generator + validator
./gradlew :agentensemble-examples:runGraphRouter # tool router with back-edges
./gradlew :agentensemble-examples:runGraphRetryWithFallback # selective feedback edge
Documentation¶
Contributors¶
This release is a significant feature drop spanning agent foundation types, executor pipeline, output and trace side channels, DAG schema, viz layer, and full doc/example coverage. ~50 files touched, ~3500 lines net added. No breaking changes; all 4400+ Java tests + 379 viz tests passing.