Loops Design
Status: Implemented.
AgentEnsemble’s workflow model is acyclic: tasks form a DAG via Task.context(),
and execution proceeds along that DAG. Cycles were inexpressible at the
workflow layer.
Three patterns repeatedly demanded cycles in real ensembles:
- Reflection — writer drafts, critic reviews, repeat until critic approves.
- Retry-until-valid — generate output, validate, regenerate on failure.
- Multi-turn negotiation — two agents take turns to consensus.
Existing primitives covered fragments of this:
- The per-agent ReAct tool-calling loop is internally cyclic, but only within one task. It can’t express writer→critic ping-pong across tasks.
PhaseReview.retryPredecessorprovides bounded one-step retry of a single predecessor, but the retry shape is hard-coded — you can’t iterate the same pair of tasks N times.
The Loop construct fills the middle: bounded, declared iteration over a sub-ensemble of tasks, expressible at build time and observable at runtime.
- Cover ~90% of what users reach for cycles to do (reflection, retry-until-valid, debate).
- Stay consistent with AgentEnsemble’s existing posture: declarative, validated at build time, observable through trace and DAG export.
- Don’t break the DAG scheduler, viz, or critical-path analysis.
- Keep the public API minimal: one new
WorkflowNodesubtype, one builder method onEnsemble.
Non-goals (deferred)
Section titled “Non-goals (deferred)”- Arbitrary back-edges between tasks. A “Task X depends on Task Y, but Y can
also loop back to X” pattern is not expressible at the top-level DAG; the
escape hatch is putting the looping pair inside a
Loop, or usingGraphfor state-machine routing. - Nested loops. Rejected at build time. The combinatorics of iteration-cap multiplication is a footgun, and no v1 use case requires it.
Loops concurrent with the parallel task DAG.Implemented. Loops are first-class nodes in the parallel dependency graph via the shadow-task pattern: each loop is wrapped in a deterministic-handlerTaskwhose handler invokes theLoopExecutorand whosecontext()is the loop’s outer-DAG deps. The parallel coordinator schedules the shadow on a virtual thread like any other Task, so loops with no deps run alongside other roots and loops with deps wait for their named upstreams. The shadow’s syntheticTaskOutputis stripped from the visible output during the post-parallel merge and replaced with the loop’s projected per-body outputs.Task.contextreferencing aLoop. Same root cause: extendingTask.contextto acceptWorkflowNodeis a public-API change with broader implications. v1 workaround: place post-loop work as the final body task.
Public API
Section titled “Public API”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) .task(publishTask) .build() .run();Ordering rules:
Workflow.SEQUENTIAL— declared tasks first (in declaration order), then declared loops (in declaration order).Workflow.PARALLEL— loops are first-class nodes in the dependency DAG. A loop with noLoop.context()runs alongside other root tasks; a loop withLoop.context(taskA, ...)waits until those tasks complete and then runs in its own virtual thread. Multiple independent loops run concurrently.Workflow.HIERARCHICAL— loops rejected at validation time.
Internal design
Section titled “Internal design”WorkflowNode interface
Section titled “WorkflowNode interface”A single marker interface implemented by both Task and Loop. Not declared
sealed because Lombok’s @Value on Task makes the class final at bytecode
time but not in the source AST during annotation processing — interacting with
the sealed-permits check is brittle. A regular interface preserves the same
intent without the ordering hazard.
Ensemble.Builder accepts both via .task(Task) (existing) and .loop(Loop)
(new). Internally each is stored in its own @Singular list; the dispatch
logic combines them into a List<WorkflowNode> when calling executeNodes.
Loop value class
Section titled “Loop value class”Lombok @Value @Builder(toBuilder = true). body is @Singular("task") so
users write .task(t) per body task. context is @Singular("context") for
outer-DAG dependencies; the PARALLEL scheduler honours these by treating the
loop as a DAG node that waits for the listed predecessors before running.
Build-time validation:
- Body non-empty.
maxIterations >= 1when set.- Stop condition required:
untilpredicate or positivemaxIterations. - Body has no nested
Loop(type system prevents this; defensive check). - Body task names/descriptions are unique.
- Body task
context()references stay within the body — outer-DAG deps belong on theLoopitself.
LoopExecutor
Section titled “LoopExecutor”Takes a SequentialWorkflowExecutor (the body runner) at construction. Per
iteration:
- If iteration > 1 and
injectFeedbackis true, replace the body’s first task viaTask.withRevisionFeedback(autoFeedback, priorOutput, iter-1)— the same primitivePhaseReviewuses for phase retry. Subsequent body tasks are rebuilt to remapcontext()references to the new instance (same identity- rewrite pattern asEnsemble.resolveAgents). - If
memoryMode == FRESH_PER_ITERATION, clear all body memory scopes viaMemoryStore.clear(scope). - Run the body via
sequentialExecutor.executeSeeded(iterationBody, ctx, {}). - Index outputs by body-task name (key by
Task.name, falling back todescriptionwhen the body task has no name — required because rebuilt tasks aren’t identity-equal to the originals). - Build a
LoopIterationContextand evaluate the predicate. Stop ontrue.
After loop termination, project outputs per LoopOutputMode and return a
LoopExecutionResult containing per-iteration history plus an
IdentityHashMap<Task, TaskOutput> keyed by the original body task instances
so the outer scheduler can resolve them via the same machinery used for regular
tasks.
MemoryStore.clear(scope)
Section titled “MemoryStore.clear(scope)”Added as a non-default abstract method on the SPI to support
FRESH_PER_ITERATION. InMemoryStore removes the scope from its
ConcurrentHashMap. EmbeddingMemoryStore throws UnsupportedOperationException
with an actionable message — the LangChain4j EmbeddingStore SPI does not expose
metadata-filtered deletion, and most underlying vector stores cannot clear by
metadata. The error directs users to either use MemoryStore.inMemory() for
loop-affected scopes or stick with ACCUMULATE.
EnsembleOutput.loopHistory side channel
Section titled “EnsembleOutput.loopHistory side channel”Three new fields parallel to taskOutputIndex:
loopHistory: Map<String, List<Map<String, TaskOutput>>>— loop name → iterations → body task name → output.loopTerminationReasons: Map<String, String>—"predicate"or"maxIterations".loopsTerminatedByMaxIterations: Set<String>— populated only whenMaxIterationsAction.RETURN_WITH_FLAGis configured.
Three convenience accessors: getLoopHistory(name), getLoopTerminationReason(name),
wasLoopTerminatedByMaxIterations(name). All excluded from equals/hashCode/
toString (same as taskOutputIndex).
Trace and viz
Section titled “Trace and viz”LoopTrace (in agentensemble-core) records per-loop summary:
iterationsRun, maxIterations, terminationReason, onMaxIterations,
outputMode, memoryMode, and per-iteration body-task name lists. Lives on
ExecutionTrace.loopTraces.
DagModel schema bumped to 1.2. DagTaskNode gains:
nodeType = "loop"for loop super-nodes (alongside existing"map","reduce","final-reduce","direct").loopMaxIterations— the configured cap.loopBody— nestedDagTaskNodelist rendered as a collapsible sub-DAG.
DagExporter emits one super-node per loop, ID-namespaced ("3", "3.0",
"3.1", …) so consumers can unambiguously reference body tasks. The loop’s
dependsOn list is populated from Loop.context() so the parallel coordinator
schedules it as a regular DAG node.
The viz TaskNode component renders nodeType === "loop" with a LOOP ≤N
badge in the header and a “Body: N tasks (role → role)” summary line.
Alternatives considered
Section titled “Alternatives considered”Option 2: Generalise PhaseReview for N-step cycles
Section titled “Option 2: Generalise PhaseReview for N-step cycles”Extend PhaseReview.retryPredecessor to bounded N-step rollback:
PhaseReview.builder() .reviewTask(reviewTask) .retryPredecessors(List.of("research", "outline")) .maxRetries(5) .build();Less new surface area but tightly coupled to PhaseReview semantics — every cycle must end at a review task. Reflection loops don’t naturally have a “reviewer” role; the second body task is just a critic. Rejected.
Option 3: First-class back-edges
Section titled “Option 3: First-class back-edges”Task.builder() .description("write") .cycleBackTo("research") .when(output -> needsMoreData(output)) .maxIterations(5) .build();Maximum flexibility, maximum disruption at the top-level DAG. The bounded
Loop construct covers iteration use cases; arbitrary state-machine routing
ships separately as Graph (see 30-graphs.md).
Verification
Section titled “Verification”End-to-end tests cover:
- Reflection loop where the critic returns
APPROVEDon iteration 3. - Retry-until-valid with predicate-driven and max-iterations-driven termination.
MaxIterationsAction.RETURN_LAST/THROW/RETURN_WITH_FLAG.- Parallel workflow with task DAG + loop, confirming declaration order.
- Memory modes —
ACCUMULATE(default no-op) andFRESH_PER_ITERATIONagainstMemoryStore.inMemory()and a stub unsupported store. - Every build-time validation rule.
- DAG round-trip — loop super-node with
nodeType: "loop",loopBody,loopMaxIterations. LoopTracepopulated onExecutionTrace.- Full
agentensemble-coreregression: 1999 tests green. - Full
agentensemble-devtoolsregression: 56 tests green. - Full
agentensemble-vizregression: 379 tests green.
See also
Section titled “See also”- 21-phase-review.md — the existing one-step retry primitive.
- 22-task-reflection.md — cross-run prompt improvement.