Skip to content

Parallel and loops ​

Control flow in a workflow is plain TypeScript, and two shapes come up in almost every one: agents side by side, such as three reviewers each looking for one kind of problem, and one agent checking another, round after round, until it approves. The first is wf.parallel, the second a for. They combine: several reviewers in one round is a parallel inside the loop.

Side by side ​

wf.parallel runs a function over a list of items, with a concurrency limit, shows the group in the progress view, and gives the results back in the order of the items:

ts
const reviews = await wf.parallel(
  LENSES,
  async (lens): Promise<Review> => {
    const reviewer = await wf.agents.open({
      key: `review-${lens}`,
      runtime: "codex",
    });
    const { outcome } = await reviewer.run({
      prompt: lensPrompt(lens),
      schema: FINDINGS,
    });
    return outcome.answered
      ? { lens, findings: outcome.value.findings }
      : {
          lens,
          findings: [],
          failed: `${outcome.kind}: ${outcome.reason}`,
        };
  },
  { label: "lenses", concurrency: 2 },
);

Each item gets its own agent, keyed by its lens, and its own turn. Two run at a time; the third starts when one finishes. reviews has one entry per lens, in the order of LENSES, whichever finished first. Fanning in is plain code over that array:

ts
const findings = reviews
  .flatMap(({ lens, findings }) =>
    findings.map((finding) => ({ lens, ...finding })),
  )
  .toSorted(
    (a, b) => Number(b.severity === "high") - Number(a.severity === "high"),
  );
const failed = reviews.flatMap(({ lens, failed }) =>
  failed ? [{ lens, reason: failed }] : [],
);
return { findings, failed };

The whole workflow is in examples/site/guide/parallel.ts, with its test beside it.

The options ​

ts
wf.parallel(items, (item, index) => …, { concurrency?, label?, timeout? })
  • concurrency is how many items run at once. It defaults to all of them, and must be a positive integer. Each call has its own limit, so a parallel of 4 inside a parallel of 3 can have 12 agents working.
  • label names the group in the run's progress view, where it shows as lenses 0/3 · 1 queued while the first two work, then lenses 1/3, with how many of its agents failed. An unlabelled parallel inside a labelled one counts as part of it.
  • timeout, "20m" or a Date, bounds the whole group. When it passes, parallel rejects and drops what it had gathered; A group of agents has how to keep it.

The function gets each item and its index. An empty list resolves to [] at once.

When one fails ​

An agent that doesn't answer is not an error. Its turn ends with an outcome such as failed or timed-out, and run resolves with it. So the fan-out above keeps going when a reviewer fails: that lens comes back with no findings and a reason, the others with theirs.

A branch that throws is different. parallel is fail-fast:

ts
return wf.parallel(LENSES, async (lens) => {
  const reviewer = await wf.agents.open({
    key: `review-${lens}`,
    runtime: "codex",
  });
  const { outcome } = await reviewer.run({
    prompt: lensPrompt(lens),
    schema: FINDINGS,
  });
  if (!outcome.answered) throw new Error(`${lens}: ${outcome.kind}`);
  return outcome.value.findings;
});

When one branch throws, parallel cancels the turns still running in the others (they end cancelled), starts no more items, waits for those turns to end, and then rejects with that first error. The results of the branches that had finished are lost with it.

So choose per workflow. Return the outcome as data when the other agents' work is still worth having; throw when one missing answer makes the rest pointless. To keep going past a throw from your own code, such as opening an agent, catch it inside the branch and return it as data, as catalogue-review does: each failure becomes an entry in the result's failures.

Round after round ​

An implementer works, a reviewer on another model reads the change and answers with a typed verdict, and the implementer makes the changes it asked for, until the reviewer approves or the rounds run out:

ts
const implementer = await wf.agents.open({
  key: "implementer",
  runtime: "claude",
});
const reviewer = await wf.agents.open({
  key: "reviewer",
  runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
});
let prompt = `Implement this task: ${task}`;
for (let round = 1; round <= 3; round++) {
  const work = await implementer.run({ prompt, schema: DONE });
  if (!work.outcome.answered)
    return wf.stop(`implementer: ${work.outcome.reason}`);
  const review = await reviewer.run({
    prompt: "Review the change.",
    schema: VERDICT,
  });
  if (!review.outcome.answered)
    return wf.stop(`reviewer: ${review.outcome.reason}`);
  if (review.outcome.value.approved)
    return { approved: true, rounds: round };
  prompt = `Make these changes:\n${review.outcome.value.changes.join("\n")}`;
}
return { approved: false, rounds: 3 };
  • Each agent keeps its session. The implementer's second turn continues from its first, so the prompt says only what is new: the changes asked for.
  • The verdict is typed. review.outcome.value.approved is a boolean the engine checked against VERDICT, so the if decides on a value, not on prose.
  • Every turn ends with an outcome, and the loop looks at it before it reads a value.
  • The loop has a bound. Three rounds, then it returns approved: false instead of going on.

The rest of this section builds that loop out, in examples/site/guide/loops.ts and its test.

Who checks whom ​

Put the reviewer on another harness or model than the implementer, so it doesn't share the implementer's blind spots: above, claude implements and codex reviews. The reviewer reads the change in the working tree itself; the author's summary is context, not evidence.

  • Keep the reviewer from fixing. Say so in its instructions, or open it in a sandbox without write, where it sees the working directory read-only.
  • Keep the verdict small. A boolean and a list of changes is all the loop needs; A typed answer has how the agent hands it back.
  • Several reviewers are a parallel inside the round, one agent per lens, and the round approves when they all do.
  • A check that needs no agent, such as routing a change before anyone works on it, can go to a decision model.

Branch on the outcome ​

outcome.answered is enough when every non-answer ends the loop the same way. When one deserves another try, branch on outcome.kind. Each round starts with the implementer's turn:

ts
let work = await implementer.run({
  prompt,
  schema: DONE,
  timeout: TURN,
});
const closed =
  work.outcome.kind === "failed" || work.outcome.kind === "timed-out";
const login =
  work.outcome.kind === "failed" && work.outcome.login !== undefined;
if (closed && !login && !replaced) {
  // The agent is closed. Once, a new one under its own key takes over.
  replaced = true;
  implementer = await wf.agents.open({
    key: "implementer-2",
    runtime: "claude",
  });
  const handover = [
    `Take over from an agent that stopped. The task: ${task}`,
    "Its change so far is in the working tree.",
  ].join("\n");
  work = await implementer.run({
    prompt: round === 1 ? handover : `${handover}\n${prompt}`,
    schema: DONE,
    timeout: TURN,
  });
}
if (!work.outcome.answered) {
  return wf.stop(
    `implementer ${work.outcome.kind}: ${work.outcome.reason}`,
  );
}

A failed or timed-out turn closes its agent, so the replacement is opened under a key of its own, implementer-2, and starts with no session: its prompt gives it the task and the changes still asked for. A missing login fails every agent on that harness alike, so the loop stops on it rather than open another. Any other non-answer, and a second failure, stops the run with the kind and the reason.

What each kind leaves of the agent is on Agents. How the loop ends is yours: return a value that says what happened, return wf.stop(reason) so the run can be continued, or let an error throw and fail it (Stopping with a reason).

Bound the loop ​

ts
const ROUNDS = 3;
const TURN = "20m";
  • Rounds, in code. A reviewer can always find something more. ROUNDS ends the loop, and the result says it wasn't approved and what is still open, for a person or the next workflow to pick up. Never write the loop as while (true).
  • Time, per turn. timeout ends a turn timed-out when it passes, and awf run --timeout bounds the whole run: Timeouts and nudges.

Without either, a turn whose agent stops without answering still ends: the engine nudges it, and the turn ends unanswered if no answer comes. What nothing stops is a loop that keeps getting answers.

In a run with stages, the whole loop goes inside one stage. A continue then redoes the loop from its first round.

Carry feedback between rounds ​

The rest of the round is the review, and what the next round is told:

ts
const review = await reviewer.run({
  prompt:
    round === 1
      ? `Review the change. Its author says: ${work.outcome.value.summary}`
      : `The author says it made your changes: ${work.outcome.value.summary}\n` +
        "Check each one, then the rest of the change.",
  schema: VERDICT,
  timeout: TURN,
});
if (!review.outcome.answered) {
  return wf.stop(
    `reviewer ${review.outcome.kind}: ${review.outcome.reason}`,
  );
}
if (review.outcome.value.approved)
  return { approved: true, rounds: round };
open = review.outcome.value.changes;
prompt = `Make these changes:\n${open.join("\n")}`;
  • Send only what is new. The implementer's session holds the task and its own earlier work, so a round's prompt is the changes, not the history.
  • Ask the reviewer about its own changes. It keeps its session too, so from the second round it is told what the author says it did and asked to check each change, then the rest.
  • Keep what matters in variables. prompt and open live in the workflow, not in an agent, so they survive a replaced agent, and open ends up in the result when the rounds run out.
  • Long loops grow the context. Between rounds, agent.compact({ prompt }) shrinks it with the harness's own compaction: Agents.

Catches ​

  • Give each branch its own agent key. Opening a key that is already open returns the same agent, and an agent takes one turn at a time, so branches that share one wait for each other.
  • Await what a branch starts. parallel resolves only once every turn started inside it has settled. Work started from a branch after its parallel has resolved is refused.
  • A parallel goes inside a stage, not stages inside a parallel. A run has one stage open at a time, so parallel branches can't each enter one (Runs).
  • Promise.all works too. It has no limit, no deadline and no progress line, and when one promise rejects, the others keep running.
  • A non-answer is not an exception. Reading outcome.value before checking the kind doesn't type-check.
  • unanswered comes after a nudge. The engine already asked the agent for its answer, so asking again in the same session is a choice, not a retry the engine skipped.
  • A replacement agent knows nothing. Its prompt and the working tree are all it has.

Testing it ​

A test scripts each agent by key, so parallel branches are read by key, not by position; to check item order, the parallel sample's test makes the first item's answer wait for the last's. A scripted noAnswer.failed(), noAnswer.timedOut() or noAnswer.silent() drives each branch of a loop for free; the loop sample's test covers approval, the bound, a replacement and a stop. Testing has the rest.

See also ​