Skip to content

v3.5.0 — Loop & Graph

Headline: AgentEnsemble gains two new top-level workflow constructs — Loop for bounded iteration and Graph for 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 (including WINDOW(n) for prompt-size control), automatic revision-feedback injection, executes concurrently with independent tasks under PARALLEL workflow.
  • 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 --strict clean.

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 on EnsembleOutput.wasLoopTerminatedByMaxIterations(name)).
  • Memory modes (LoopMemoryMode): ACCUMULATE (default), FRESH_PER_ITERATION (via new MemoryStore.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 observabilityEnsembleListener.onLoopIterationCompleted event 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 for RETURN_WITH_FLAG.
  • LoopTrace on ExecutionTrace.loopTraces — name, iterations run, termination reason, per-iteration body-task names.
  • Viz supportDagModel schema 1.2 adds nodeType: "loop" super-nodes with loopMaxIterations and an expandable loopBody sub-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 GraphPredicate lambda or null predicate for unconditional fallbacks. Optional human-readable conditionDescription for visualisation.
  • Arbitrary back-edges — a state can be visited many times; the executor walks a true state machine, not a DAG.
  • Termination via Graph.END sentinel target on any edge, or hitting maxSteps (default 50). MaxStepsAction.RETURN_LAST | THROW | RETURN_WITH_FLAG mirror 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 observabilityEnsembleListener.onGraphStateCompleted event 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-step GraphStep records (state, step number, output, next state) in execution order. Visits to the same state appear multiple times.
  • getGraphTerminationReason()"terminal" or "maxSteps".
  • wasGraphTerminatedByMaxSteps() — flag for RETURN_WITH_FLAG.
  • GraphTrace on ExecutionTrace.graphTrace — name, start state, termination reason, step count, per-step state name + step number.
  • Viz supportDagModel schema 1.3 adds mode: "graph" for graph ensembles, with nodeType: "graph-state" for state nodes and "graph-end" for the terminal cap. The agentensemble-viz dashboard 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. New DagExporter.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), reserved Graph.END state name, missing or unknown start, maxSteps < 1.
  • Mutual exclusion with tasks, loops, phases — a graph ensemble is exclusive, enforced by EnsembleValidator.

See the Graphs guide and Graphs examples.

Shared infrastructure

Both constructs share a refactor that's also useful in isolation:

  • WorkflowNode interface — a marker interface implemented by Task, Loop, and Graph. Lets Ensemble.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 support LoopMemoryMode.FRESH_PER_ITERATION. InMemoryStore removes the scope; EmbeddingMemoryStore throws an actionable UnsupportedOperationException directing users to either switch the loop's scopes to inMemory() or use ACCUMULATE mode (vector stores generally cannot delete by metadata filter).
  • Task.withRevisionFeedback(feedback, priorOutput, attempt) — the existing primitive used by PhaseReview is now also used by both Loop and Graph for 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

  • Loop body execution reuses SequentialWorkflowExecutor.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.
  • Graph is single-threaded by design (one current state at a time). Memory overhead is O(steps) for the per-step history list plus O(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.HIERARCHICAL rejects both Loops and Graphs. Use SEQUENTIAL, PARALLEL, or no explicit workflow setting (the default).
  • Task.context still accepts only Task instances (not Loop or Graph). 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.
  • EmbeddingMemoryStore does not support clear(scope) — vector stores generally cannot delete by metadata filter. Selecting LoopMemoryMode.FRESH_PER_ITERATION against an embedding store throws an actionable UnsupportedOperationException pointing to either MemoryStore.inMemory() or LoopMemoryMode.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 — implements WorkflowNode; otherwise unchanged.
  • Ensemble.Builder — gains .loop(Loop), .graph(Graph), .onLoopIterationCompleted(...), .onGraphStateCompleted(...).
  • EnsembleOutput — adds getLoopHistory(name), getLoopTerminationReason(name), wasLoopTerminatedByMaxIterations(name), getGraphHistory(), getGraphTerminationReason(), wasGraphTerminatedByMaxSteps().
  • EnsembleListener — adds default onLoopIterationCompleted and onGraphStateCompleted methods.
  • MemoryStore — adds abstract clear(scope) method (impls in InMemoryStore and EmbeddingMemoryStore).
  • WorkflowExecutor — adds default executeNodes(List<WorkflowNode>, ...) (overridden by SequentialWorkflowExecutor and ParallelWorkflowExecutor to handle loops).
  • DagModel — schema 1.3; adds mode, graphEdges, graphStartStateId, graphTerminationReason, graphStepsRun.
  • DagTaskNode — adds nodeType values "loop", "graph-state", "graph-end"; adds loopMaxIterations and loopBody for loop super-nodes.
  • ExecutionTrace — adds loopTraces and graphTrace.

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.