Skip to content
tnsaiDocs
GitHub

Recursive task solver

RecursiveTaskSolver in com.tnsai.coordination.roma splits a task that is too big for one call into sub-tasks, solves them, and combines the answers. Sub-tasks that are still too big are split again, so the work forms a tree. A verifier checks each answer before it is used.

How a task is solved

solve(task) starts at depth 0. At each depth:

  1. If the depth has reached maxDepth, or the Atomizer says the task is atomic, the task is solved directly.
  2. Otherwise the TaskDecomposer splits it into sub-tasks. An empty list means the task is solved directly after all.
  3. Each sub-task is solved at the next depth, one after another. If any sub-task fails, the whole branch fails at once.
  4. The solver agent combines the sub-results. Its prompt lists the original task and each sub-task with its result.
  5. The Verifier checks the combined answer once. If it rejects the answer, the branch fails.

Solving directly means the solver agent gets the task text through chat, and the verifier checks the answer. A rejected answer is tried again, up to maxRetries attempts in total. The retry sends the same task text; the verifier's feedback is recorded in the trace but not passed to the solver. When every attempt is rejected, the task fails.

Building a solver

Builder methodDefaultMeaning
solver(agent)requiredAnswers atomic tasks and combines sub-results
decomposer(decomposer)requiredSplits a task into sub-tasks
atomizer(atomizer)Atomizer.depthBased(3)Decides whether to solve a task directly
verifier(verifier)Verifier.alwaysAccept()Accepts or rejects an answer
maxDepth(n)5Depth at which every task is solved directly
maxRetries(n)2Attempts per atomic task, including the first

With the defaults, the depth-based atomizer stops splitting at depth 3, before maxDepth is reached.

package com.example.tnsai.docs;

import com.tnsai.agents.Agent;
import com.tnsai.coordination.roma.Atomizer;
import com.tnsai.coordination.roma.RecursiveTaskSolver;
import com.tnsai.coordination.roma.RecursiveTaskSolver.SolveResult;
import com.tnsai.coordination.roma.RecursiveTaskSolver.SolveTrace;
import com.tnsai.coordination.roma.TaskDecomposer;
import com.tnsai.coordination.roma.Verifier;

/** Compilation contract for multi-agent/recursive-solver.md. */
public final class RecursiveSolverExample {

    private RecursiveSolverExample() {
    }

    static SolveResult solve(Agent solver, Agent planner, Agent reviewer) {
        RecursiveTaskSolver roma = RecursiveTaskSolver.builder()
            .solver(solver)
            .decomposer(TaskDecomposer.llmBased(planner))
            .atomizer(Atomizer.depthBased(2))        // depth 0 and 1 split, depth 2 is solved directly
            .verifier(Verifier.llmBased(reviewer))
            .maxDepth(4)
            .maxRetries(3)                           // attempts per atomic task
            .build();

        SolveResult result = roma.solve("Write a migration guide for moving from REST to gRPC");

        for (SolveTrace trace : result.traces()) {
            System.out.println("  ".repeat(trace.depth()) + trace.type() + " " + trace.detail());
        }
        if (!result.success()) {
            System.out.println("No accepted answer after " + result.durationMs() + " ms");
        }
        return result;
    }

    static Verifier lengthCheck() {
        return (task, result, attempt) -> result.length() > 200
            ? Verifier.Result.accept("Long enough")
            : Verifier.Result.reject("Too short on attempt " + attempt);
    }
}

Components

All three components are functional interfaces, so a lambda works in place of the built-ins.

InterfaceMethodBuilt-ins
AtomizerisAtomic(task, depth)depthBased(maxDepth): atomic once depth >= maxDepth. llmBased(agent): asks whether the task is atomic; an answer starting with "yes" means atomic.
TaskDecomposerdecompose(task, depth)llmBased(agent): asks for 2–5 independent sub-tasks and keeps the lines that start with - or *
Verifierverify(task, result, attemptNumber)llmBased(agent): asks for ACCEPT or REJECT and accepts an answer that starts with "ACCEPT". alwaysAccept().

A verifier returns Verifier.Result.accept(feedback) or Verifier.Result.reject(feedback). For the combined answer of a decomposed task, attemptNumber is always 1.

The LLM-based components parse plain text. If the decomposer's agent does not answer with a bulleted list, no sub-tasks are found and the task is solved directly.

Result and traces

SolveResult has success, output, traces and durationMs. When the task fails, success is false and output is null; there is no partial answer.

Each SolveTrace has a type, the task, a detail, the depth and an attempt number:

typedetailattempt
DECOMPOSEDThe number of sub-tasks and their text0
SOLVEDThe answerThe attempt number
AGGREGATEDThe combined answer0
VERIFIEDACCEPTED: … or REJECTED: … with the verifier's feedback0

Answers and sub-task text in detail are cut to 80 characters; verifier feedback is kept whole. The rejection of an atomic task's last attempt is not traced.