Many useful agent behaviours are loops. Draft an answer, critique it, revise. Generate SQL, run EXPLAIN on it, fix the error. Call a tool, check the result, retry with a different query. Written by hand, each is a while loop: repeat the body while the result is not good enough and you have attempts left. The Java Agent Development Kit (ADK) packages that loop as LoopAgent, a workflow agent that repeats its sub-agents in code, without a model deciding the control flow.

The class is small, and that is the reason to read it closely: its behaviour is precisely what its few lines say, including some consequences that surprise people. This article reads the loop from the source, builds a SQL-refinement loop with a model writer and a deterministic checker, and covers the design decisions that make loops converge, stay cheap and fail visibly: how to signal done, what state carries between iterations, what happens when the cap is hit, why nested loops need care, and how to test all of it.

Advertisement

The while loop you are actually writing

Every agent loop has the same four parts: a body, a stopping test, a cap and a carrier of progress between iterations. In plain Java:

String draft = null, feedback = null;
for (int i = 0; i < MAX && !done; i++) {          // cap
    draft = write(question, draft, feedback);     // body
    feedback = check(draft);                      // stopping test
    done = feedback == null;
}                                                  // progress lives in draft and feedback

LoopAgent maps each part onto ADK concepts. The body is the list of sub-agents, run in order each iteration. The stopping test is any event that carries the escalate action. The cap is maxIterations. Progress lives in session state, usually written through an LLM agent's outputKey. Keeping those four parts in mind is most of the design; the rest is choosing who evaluates the stopping test, a model or your code.

What LoopAgent does, from the source

The non-resumable path of LoopAgent.runAsyncImpl in the google/adk-java repository is a single RxJava expression:

// LoopAgent.runAsyncImpl, non-resumable branch (google/adk-java, main)
return Flowable.fromIterable(subAgents)
    .concatMap(subAgent -> subAgent.runAsync(invocationContext))
    .repeat(maxIterations != null ? maxIterations : Integer.MAX_VALUE)
    .takeUntil(LoopAgent::hasEscalateAction);
// hasEscalateAction: event.actions().escalate().orElse(false)
LoopAgent (non-resumable path): run sub-agents in order, repeat, stop at the first escalate eventLoopAgentmaxIterations = 4writer (LlmAgent)outputKey sqlchecker (BaseAgent)deterministicsession statesql, feedback, statusescalate = trueends the whole loopcap reachedno special eventiteration ithenwrites sqlstatus, feedbackif validrepeat if not validafter 4 passesDownstream agents read status to tell success from exhaustion
A two-agent refinement loop. The checker writes status and feedback to state each pass and escalates only on success; hitting the cap ends the loop without any special event.

Five consequences follow. They are derived from this code path, so verify them on your version; the class also has resumable branches that track iteration state differently.

  1. Escalation stops immediately. takeUntil emits the escalating event and then cancels the upstream. Sub-agents listed after the one that escalated do not run in that pass.
  2. No cap means effectively unbounded. A null maxIterations becomes Integer.MAX_VALUE. Always set it.
  3. Hitting the cap is silent. When the repeat count runs out, the stream just completes. No event says 'gave up', so success and exhaustion look alike unless your state says otherwise.
  4. Escalate travels outward. The escalating event flows through every enclosing Flowable. An escalate inside a nested inner LoopAgent reaches the outer loop's takeUntil too, and ends the outer loop.
  5. No live mode. runLiveImpl throws UnsupportedOperationException, so loops belong in request-response agents, not streaming audio sessions.
Advertisement

Two ways to say done

The first is to let a model decide. Recent ADK Java releases ship ExitLoopTool.INSTANCE, a function tool named exit_loop that sets escalate and skipSummarization on the tool context's actions. Give it to a critic LlmAgent instructed to call it when the draft meets the bar. If your version lacks the class, the tool is three lines to write.

The second is to let code decide: a custom agent that escalates when a deterministic test passes. Prefer code whenever the test can be computed. A model judging its own or a sibling's work is inconsistent, sometimes approving broken output and sometimes never approving, burning every iteration. A parser, validator or database EXPLAIN gives the same answer every time and costs no tokens. When quality needs judgement, as with tone, use a model critic with a deterministic floor and a firm cap.

A writer and a deterministic checker

The example turns a natural-language question into a PostgreSQL query. The writer is an LlmAgent that reads the schema, the question, its previous attempt and the checker's feedback from state. The checker is a BaseAgent that validates the SQL, records the verdict, and escalates only on success.

import com.google.adk.agents.LlmAgent;
import com.google.adk.agents.LoopAgent;
import com.google.adk.agents.SequentialAgent;

static final LlmAgent WRITER = LlmAgent.builder()
    .name("sql_writer")
    .model("gemini-2.5-flash")                        // use a model your project can call
    .instruction("Schema:\n{schema}\n\nQuestion: {question}\n\n"
        + "Previous attempt:\n{sql?}\n\nProblems found:\n{feedback?}\n\n"
        + "Reply with one PostgreSQL SELECT statement and nothing else.")
    .includeContents(LlmAgent.IncludeContents.NONE)   // state carries everything it needs
    .outputKey("sql")
    .build();

static final LoopAgent REFINE = LoopAgent.builder()
    .name("sql_refine")
    .description("Drafts SQL and repeats until the checker accepts it or 4 passes are used.")
    .subAgents(WRITER, new SqlCheck(new PostgresExplainValidator(dataSource)))
    .maxIterations(4)
    .build();

static final SequentialAgent ROOT = SequentialAgent.builder()
    .name("sql_assistant")
    .subAgents(REFINE, new LoopOutcome())
    .build();

The instruction uses {sql?} and {feedback?}. The question mark marks the placeholder as optional: on the first iteration neither key exists, and a plain {feedback} would fail the turn with an IllegalArgumentException reporting that the context variable was not found. Optional placeholders resolve to an empty string instead. The schema and question keys are required, so seed them when the session is created.

public final class SqlCheck extends BaseAgent {
  static final String STATUS = "sql_status";       // "passed" or "failed"
  static final String ATTEMPTS = "sql_attempts";
  private final SqlValidator validator;            // your interface: Optional<String> problem(String sql)

  public SqlCheck(SqlValidator validator) {
    super("sql_check", "Validates the latest SQL draft and escalates when it passes.",
          List.of(), List.of(), List.of());
    this.validator = validator;
  }

  @Override
  protected Flowable<Event> runAsyncImpl(InvocationContext ctx) {
    return Flowable.defer(() -> {
      Map<String, Object> state = ctx.session().state();
      Object sql = state.get("sql");
      Optional<String> problem = (sql == null)
          ? Optional.of("no SQL was produced")
          : validator.problem(String.valueOf(sql));
      int attempts = ((Number) state.getOrDefault(ATTEMPTS, 0)).intValue() + 1;

      ConcurrentHashMap<String, Object> delta = new ConcurrentHashMap<>();
      delta.put(STATUS, problem.isEmpty() ? "passed" : "failed");
      delta.put("feedback", problem.orElse("none"));
      delta.put(ATTEMPTS, attempts);

      EventActions.Builder actions = EventActions.builder().stateDelta(delta);
      if (problem.isEmpty()) {
        actions.escalate(true);                      // ends the LoopAgent after this event
      }
      return Flowable.just(Event.builder()
          .id(Event.generateEventId())
          .invocationId(ctx.invocationId())
          .author(name())
          .branch(ctx.branch().orElse(null))
          .actions(actions.build())
          .timestamp(System.currentTimeMillis())
          .build());
    });
  }

  @Override
  protected Flowable<Event> runLiveImpl(InvocationContext ctx) {
    return Flowable.error(new UnsupportedOperationException("live mode not supported"));
  }
}

The checker's validator should be a pure function you can test without ADK: parse the statement, reject anything that is not a single SELECT, then run EXPLAIN against a read-only connection with a short statement timeout. EXPLAIN plans the query without executing it, which catches unknown tables and columns and type errors, not only syntax.

State across iterations

State is the loop's memory, and three details decide whether it helps or hurts. First, outputKey overwrites. Each pass the writer replaces sql with its newest draft, so if you want the history of attempts, for debugging or to stop the model repeating itself, append it to a list under another key in the checker.

Second, conversation history grows. By default an LlmAgent sees the session's prior events, so iteration k's writer call includes every earlier draft and verdict. With IncludeContents.NONE the writer sees only its instruction and the current turn, and the loop passes exactly what it needs through state. That makes each call's input constant and the prompt easy to reason about.

Third, state survives the loop. sql_status, feedback and sql_attempts are still there after the loop ends, which is precisely what solves the silent-cap problem. A stage after the loop, LoopOutcome in the example, reads sql_status: 'passed' means the checker escalated; 'failed' means the cap ran out, and the stage can tell the user plainly that no valid query was found, attaching the last error, rather than presenting a broken query as an answer. Reset these keys and sql at the start of each new question, or a stale draft or 'passed' from the previous run will mask a failure.

Nested loops and the escalate footgun

Loops compose with other workflow agents: a loop body can contain a SequentialAgent or a ParallelAgent, and an escalate from any depth inside the body stops the loop, which is usually what you want. Nesting a LoopAgent inside another LoopAgent is where it goes wrong. Because the inner loop's escalating event flows out through the outer Flowable, the outer loop's takeUntil sees it too, so 'the inner draft is good' ends the outer loop after its first pass.

Two fixes work. Let the inner loop exit on its cap alone and have the outer loop's checker evaluate the combined result; or write the outer loop as a custom BaseAgent that runs the inner loop and decides whether to repeat from state, not from events. The general custom-agent technique is shown in the sequential chain article's gated sequence. The exits available to workflows in general, escalation, the cap and errors, are compared in workflow orchestration architecture.

Worked example: cost and latency of one question

Assume a schema description of 2,500 tokens, a question of 50, a draft of 150 and feedback of 60. With IncludeContents.NONE each writer call reads roughly 2,500 + 50 + 150 + 60, about 2,760 tokens, and writes about 150. The checker makes no model call; EXPLAIN takes a few milliseconds.

If 70 percent of questions pass on the first attempt, 20 percent on the second, 6 percent on the third and 4 percent never pass within four, the expected number of writer calls is 0.7 x 1 + 0.2 x 2 + 0.06 x 3 + 0.04 x 4 = 1.44. Expected input is about 4,000 tokens per question, and the 4 percent of failures cost four calls each, 11,000 tokens, for no answer. With default contents instead, iteration k also rereads every earlier draft, so a four-pass failure reads about 12,600 tokens and the growth is quadratic in the cap. Latency follows the call count: at around a second and a half per writer call, the median question takes about 1.5 seconds and a failure about 6.

The cap bounds the worst case. Feedback quality decides how often pass two succeeds, so a precise message such as 'column orders.amount does not exist' is worth more than a stronger model. A high failure rate usually means a missing input, such as undocumented columns.

Testing a loop

Test three layers. The validator is a plain Java class and gets ordinary unit tests. The loop's control flow is tested with a scripted writer, a small BaseAgent that writes canned SQL on each call, so no model is involved and the test is deterministic. And the end-to-end behaviour with a real model is measured by an evaluation set, not asserted in unit tests.

@Test
void checkerIsPureAndTotal() {
  SqlValidator v = new PostgresExplainValidator(testDataSource);
  assertTrue(v.problem("SELECT id FROM orders WHERE total > 100").isEmpty());
  assertTrue(v.problem("SELEC id FROM orders").isPresent());         // syntax error
  assertTrue(v.problem("DELETE FROM orders").isPresent());           // not a SELECT
  assertTrue(v.problem("SELECT id FROM no_such_table").isPresent()); // EXPLAIN fails
}

// Loop-level tests with a scripted writer (a BaseAgent that writes canned SQL per call):
//   exits on pass 1  -> sql_status "passed", sql_attempts 1, checker ran once
//   exits on pass 3  -> sql_attempts 3, no events after the escalating one
//   never valid      -> sql_status "failed", sql_attempts 4, LoopOutcome reports exhaustion

The case that is most often missing is the never-valid path. Without it nobody notices that exhaustion is silent until a user receives a broken answer.

Operating loops in production

Emit three numbers per run: iterations at exit, whether the exit was escalate or cap, and tokens per iteration. A mass at 1 with a thin tail is healthy; a rising share at the cap means inputs changed, the model regressed or the checker got stricter. Alert on the cap-hit rate, not individual failures, and record it with the callbacks and tracing in ADK Java observability.

Failure modes and trade-offs

  • No cap. A null maxIterations plus a critic that never approves loops until something else, a timeout or the budget, stops it.
  • Silent exhaustion. The last draft is returned as if it were accepted. Record status in state and branch on it after the loop.
  • Oscillation. The writer alternates between two wrong answers. Feed back the history of attempts, or lower the temperature on later passes.
  • Self-grading. One model writes and approves its own work. Use code where possible, or a different model or prompt for the critic.
  • Checker placed first. If the checker runs before the writer in the body, it checks the previous pass's draft, and escalation happens one pass late.
  • Nested escalate. An inner loop's success ends the outer loop.

The central trade-off is between the cap and the answer rate: each extra iteration raises the pass rate a little and the worst-case cost and latency linearly. Choose the cap from the measured pass-at-k curve, usually where the next iteration adds under a percentage point. When the choice between a loop and another shape is unclear, the orchestration decision guide compares them.

What to do next

  1. Find every LoopAgent in your code and confirm each sets maxIterations.
  2. Replace model-judged exits with a deterministic checker wherever the stopping test can be computed.
  3. Write the verdict and attempt count to state in the checker, and add a stage after the loop that reports exhaustion explicitly.
  4. Use optional placeholders for keys that do not exist on the first iteration, and seed the required ones at session creation.
  5. Try IncludeContents.NONE on the loop's writer and compare tokens per run.
  6. Add the three loop tests: exit on pass one, exit mid-way, never valid.
  7. Plot the iterations-at-exit histogram for a week and set the cap from it.
Key takeaway: LoopAgent is a while loop in RxJava: it runs its sub-agents in order, repeats up to maxIterations, and stops at the first event carrying escalate, skipping the rest of that pass. An unset cap is effectively unbounded, reaching the cap emits nothing, and an escalate from a nested loop ends the outer loop too. Build loops as a writer plus a deterministic checker that records status, feedback and attempts in state and escalates only on success; use optional placeholders for first-pass keys, keep the writer's context small, branch on status after the loop, and choose the cap from measured pass rates.