Skip to content
tnsaiDocs
GitHub

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:

RunnerOrderOn failure
DAGExecutorDependency graph, independent steps in parallelDependents fail, other branches keep running
PipelineExecutorList orderStops at the failed step
SagaExecutorList orderStops and runs compensation steps in reverse
ConditionalWorkflowList order, with branches and loopsStops at a failed plain step
ExecutionPlanList order, in the backgroundStops 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 methodEffect
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, has and getData() (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) and getAllStepResults() return the recorded StepResults.

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 under Input from '<id>':.
  • timeout(duration) limits the whole run, 5 minutes by default. When it expires, execute returns a failure with the results collected so far.
  • When any step fails, error lists 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 under step.<id>.output and passed to its dependents.
  • Dependency outputs are recorded by a separate callback, so a condition that reads step.<dep>.output can 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 methodEffect
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 error Cancelled at step <i>/<n>. Cancelling during the last step has no effect, and the plan completes. cancel() sets CANCELLED in any state, so a plan cancelled before start can no longer start.
  • currentStepIndex(), totalSteps() and completedSteps() 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;
        });
    }
}