Workflows
com.tnsai.coordination.workflow runs a fixed set of agent steps in an order you define. Five runners share the same step, context and result types:
| Runner | Order | On failure |
|---|---|---|
DAGExecutor | Dependency graph, independent steps in parallel | Dependents fail, other branches keep running |
PipelineExecutor | List order | Stops at the failed step |
SagaExecutor | List order | Stops and runs compensation steps in reverse |
ConditionalWorkflow | List order, with branches and loops | Stops at a failed plain step |
ExecutionPlan | List order, in the background | Stops at the failed step; can be paused and cancelled |
Each step is one chat call to one agent. The runners build the prompt themselves, so the step's name is the instruction the agent sees.
Steps
WorkflowStep is a record with id, name, agent, dependencies, condition, compensation and timeoutMs. The id and the agent are required, and timeoutMs must be 0 or more; 0 means no timeout.
| Factory or method | Effect |
|---|---|
WorkflowStep.of(id, agent) | A step whose name is its id |
WorkflowStep.of(id, agent, dependencies) | The same, with dependency ids. Only DAGExecutor reads them. |
withCondition(predicate) | Runs the step only when the predicate on the context is true |
withCompensation(step) | The step SagaExecutor runs to undo this one |
withTimeout(ms) | Fails the step when the agent takes longer |
isRoot() | True when the step has no dependencies |
When the agent needs a longer instruction than the id, call the record constructor with all seven fields, as the execution plan example does.
A step timeout stops waiting for the agent, but the chat call keeps running on the common fork-join pool.
Context and results
WorkflowContext holds the data the steps share. Create it with an id and, optionally, initial data: new WorkflowContext("wf-1", Map.of("region", "eu-west-1")). It is backed by concurrent maps, so parallel steps can use it.
put,get,getOrDefault,hasandgetData()(a copy) work on the data map. The map does not accept null values.- Every runner stores a successful step's output under
step.<id>.output. getStepResult(id)andgetAllStepResults()return the recordedStepResults.
StepResult is a record with stepId, success, output, durationMs and error. A runner returns a WorkflowResult with workflowId, success, stepResults, totalDurationMs and error; completedCount() and failedCount() count the step results by success.
DAG
DAGExecutor runs steps as soon as their dependencies finish. build() checks the graph: a dependency on an unknown step throws IllegalArgumentException, and a cycle throws IllegalStateException. A DAG with no steps also throws IllegalStateException.
- Steps with no dependencies start at once on a thread pool created for the run.
- A step whose dependency failed is not run. Its result is a failure with the error
Dependency '<id>' failed. - The prompt is
Execute step: <name>, followed by the output of each successful dependency underInput from '<id>':. timeout(duration)limits the whole run, 5 minutes by default. When it expires,executereturns a failure with the results collected so far.- When any step fails,
errorlists every failed step as<id>: <error>, separated by semicolons.
Conditions behave differently from the other runners:
- The condition of a step with no dependencies is never checked; the step always runs.
- A dependent step whose condition is false is not run, but it counts as a success with the output
SKIPPED. That output is stored understep.<id>.outputand passed to its dependents. - Dependency outputs are recorded by a separate callback, so a condition that reads
step.<dep>.outputcan run before that key is set. Base conditions on data you put in the context before the run.
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.coordination.workflow.DAGExecutor;
import com.tnsai.coordination.workflow.WorkflowContext;
import com.tnsai.coordination.workflow.WorkflowContext.StepResult;
import com.tnsai.coordination.workflow.WorkflowResult;
import com.tnsai.coordination.workflow.WorkflowStep;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/** Compilation contract for multi-agent/workflows.md DAG. */
public final class WorkflowDagExample {
private WorkflowDagExample() {
}
static WorkflowResult research(Agent webAgent, Agent paperAgent, Agent writer, Agent translator) {
WorkflowContext context = new WorkflowContext("market-report", Map.of("translate", true));
WorkflowResult result = DAGExecutor.builder()
.step("web", webAgent) // runs in parallel
.step("papers", paperAgent) // with this one
.step(WorkflowStep.of("merge", writer, List.of("web", "papers"))
.withTimeout(60_000))
.step(WorkflowStep.of("translate", translator, List.of("merge"))
.withCondition(ctx -> ctx.getOrDefault("translate", false)))
.timeout(Duration.ofMinutes(10))
.build()
.execute(context);
for (StepResult step : result.stepResults()) {
System.out.println(step.stepId() + " " + (step.success() ? "ok" : step.error()));
}
if (result.success()) {
System.out.println(context.<String>get("step.merge.output"));
}
return result;
}
}executeAsync(context) runs the same graph on the common fork-join pool and returns a CompletableFuture<WorkflowResult>.
Pipeline and saga
PipelineExecutor and SagaExecutor run the steps one after another in the order you add them; dependencies are ignored. A step whose condition is false is left out of the results. The first failed step stops the run with the error Failed at step '<id>': <error>. Both builders throw IllegalStateException without steps, and both have executeAsync.
The prompt is Execute step: <name>, followed by every earlier successful output under Previous step '<id>' output:. These come from a hash map, so their order is not the step order. A pipeline also appends the context data, which repeats the outputs stored under step.<id>.output.
When a saga step fails, the saga runs the compensation step of every completed step, newest first. The failed step's own compensation does not run, and steps without one are passed over. A failed compensation is logged and the rest still run. Compensation results are added to stepResults and recorded in the context as compensate.<id>.
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.coordination.workflow.SagaExecutor;
import com.tnsai.coordination.workflow.WorkflowContext;
import com.tnsai.coordination.workflow.WorkflowResult;
import com.tnsai.coordination.workflow.WorkflowStep;
/** Compilation contract for multi-agent/workflows.md pipeline and saga. */
public final class WorkflowSagaExample {
private WorkflowSagaExample() {
}
static WorkflowResult bookTrip(Agent flights, Agent hotels, Agent mailer) {
WorkflowContext context = new WorkflowContext("trip-42");
WorkflowResult result = SagaExecutor.builder()
.step(WorkflowStep.of("book-flight", flights)
.withCompensation(WorkflowStep.of("cancel-flight", flights)))
.step(WorkflowStep.of("book-hotel", hotels)
.withCompensation(WorkflowStep.of("cancel-hotel", hotels)))
.step("send-confirmation", mailer) // nothing to undo
.build()
.execute(context);
if (!result.success()) {
System.out.println(result.error()); // Failed at step 'book-hotel': ...
WorkflowContext.StepResult undo = context.getStepResult("compensate.book-flight");
if (undo != null) {
System.out.println("Flight cancelled: " + undo.success());
}
}
return result;
}
}Branches and loops
ConditionalWorkflow adds branches and loops to a list of steps. Its steps are not WorkflowStep records; the builder takes an id and an agent directly.
| Builder method | Effect |
|---|---|
step(id, agent) | A plain step |
step(id, agent, condition) | A step that runs only when the condition is true |
step(id, agent, dependencies) | Accepted, but the dependencies are not checked |
branch(afterStepId, branches...) | After that step succeeds, runs the first branch whose condition is true |
loop(id, agent, loopCondition) | A step that repeats while the loop condition holds |
Build branches with Branch.when(predicate).then(stepId, agent), and end the list with Branch.otherwise().then(stepId, agent) for a default. A branch target runs once, as part of the branch, so do not add it as a plain step too.
LoopCondition.maxIterations(n) repeats a step n times. LoopCondition.whileTrue(max, predicate) repeats while the predicate is true, at most max times. Each pass is recorded as <id>[<n>], starting at 0.
The prompt is Execute step: <id>, with (iteration <n>) for a loop pass, followed by the context data. Earlier outputs reach the agent only through that data.
Only a failed plain step stops the run, with the error Step '<id>' failed: <error>. A failed branch or loop pass is in stepResults, but the workflow carries on and can still succeed. Check failedCount() as well as success().
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.coordination.workflow.Branch;
import com.tnsai.coordination.workflow.ConditionalWorkflow;
import com.tnsai.coordination.workflow.LoopCondition;
import com.tnsai.coordination.workflow.WorkflowContext;
import com.tnsai.coordination.workflow.WorkflowResult;
/** Compilation contract for multi-agent/workflows.md branches and loops. */
public final class WorkflowBranchExample {
private WorkflowBranchExample() {
}
static WorkflowResult triage(Agent classifier, Agent billing, Agent support, Agent editor) {
WorkflowResult result = ConditionalWorkflow.builder()
.step("classify", classifier)
.branch("classify",
Branch.when(ctx -> ctx.<String>get("step.classify.output").contains("billing"))
.then("billing-reply", billing),
Branch.otherwise().then("support-reply", support))
.loop("polish", editor, LoopCondition.whileTrue(3,
ctx -> !ctx.getOrDefault("step.polish.output", "").contains("DONE")))
.build()
.execute(new WorkflowContext("ticket-7"));
if (result.failedCount() > 0) {
System.out.println(result.failedCount() + " branch or loop steps failed");
}
return result;
}
}Execution plans
ExecutionPlan runs WorkflowSteps in order on the common fork-join pool and lets you pause, resume and cancel the run. start(context) returns a CompletableFuture<WorkflowResult>; calling it a second time throws IllegalStateException. Without id(...), the builder generates an id of the form plan-<8 characters>, and the result carries the plan id instead of the context id.
state() is one of CREATED, RUNNING, PAUSED, CANCELLED, COMPLETED and FAILED.
pause()takes effect before the next step; the running step finishes first.resume()continues a paused plan.cancel()ends the run before the next step with the errorCancelled at step <i>/<n>. Cancelling during the last step has no effect, and the plan completes.cancel()setsCANCELLEDin any state, so a plan cancelled beforestartcan no longer start.currentStepIndex(),totalSteps()andcompletedSteps()report progress.
The prompt is only Execute step: <name>. Earlier outputs and context data are not included, so put everything the agent needs in the step name.
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.coordination.workflow.ExecutionPlan;
import com.tnsai.coordination.workflow.WorkflowContext;
import com.tnsai.coordination.workflow.WorkflowResult;
import com.tnsai.coordination.workflow.WorkflowStep;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/** Compilation contract for multi-agent/workflows.md execution plans. */
public final class WorkflowPlanExample {
private WorkflowPlanExample() {
}
static CompletableFuture<WorkflowResult> migrate(Agent dba) {
ExecutionPlan plan = ExecutionPlan.builder()
.id("schema-migration")
.step(new WorkflowStep("backup", "Back up the orders database",
dba, List.of(), Optional.empty(), Optional.empty(), 120_000))
.step(new WorkflowStep("migrate", "Apply the pending orders schema migrations",
dba, List.of(), Optional.empty(), Optional.empty(), 0))
.build();
CompletableFuture<WorkflowResult> run = plan.start(new WorkflowContext("migration"));
plan.pause(); // after the current step
System.out.println(plan.state() + " at step "
+ plan.currentStepIndex() + "/" + plan.totalSteps());
plan.resume();
return run.thenApply(result -> {
System.out.println(plan.state() + ": " + result.completedCount() + " steps done");
return result;
});
}
}