Cookbook: a multi-agent research team§
A team of LLM agents writes a research brief, and every agent turn is a durable step. A supervisor
splits the topic into subtasks, specialists answer them in parallel, a writer and a critic revise
the draft until the critic approves, and a human editor signs it off. Read the source at
example/src/main/java/com/wiggle/agents/ResearchTeam.java
alongside this page; run it end to end, offline, with:
./gradlew :example:runAgents --args="vector databases"
against Claude with:
ANTHROPIC_API_KEY=... ./gradlew :example-claude:runAgents --args="vector databases"
and against OpenAI with:
OPENAI_API_KEY=... ./gradlew :example-openai:runAgents --args="vector databases"
Nothing here is an agent framework. The team is an ordinary workflow over operators the
cookbook already covers — a spawning step and its combine, repeatWhile,
thenAwait with a deadline, oneOf — and an agent is an ordinary handler method that calls a model.
The team§
| Agent | Step | What it does |
|---|---|---|
| supervisor | plan |
splits the topic into search: and numbers: subtasks; starts one branch per subtask |
| researcher | research |
answers a search: subtask |
| analyst | analyse |
answers a numbers: subtask with an estimate |
| fact checker | factCheck |
checks the analyst's estimate — the second agent in the same branch |
| writer | draft, revise |
writes the brief from every finding, then revises it against the critique |
| critic | critique |
replies APPROVED, or says what to change |
| editor (human) | editor-approval signal |
approves or rejects; silence past the deadline shelves the brief |
The topology§
return FlowSpec.define(FLOW, 1, Brief.class, TeamSteps.class, (f, s) -> {
var reviewed = f
.thenApply(s::plan, MODEL_RETRY) // starts one specialist branch per subtask
.combine(s::collect) // runs once every specialist is done
.thenApply(s::draft, MODEL_RETRY)
.repeatWhile(s::needsRevision, loop -> loop
.thenApply(s::critique, MODEL_RETRY)
.thenApply(s::revise, MODEL_RETRY))
// no worker is held while the editor reads
.thenAwait(APPROVAL, approvalTimeout, late -> late.thenApply(s::shelveUnanswered));
return Wiggle.oneOf(
reviewed.when(s::approved).thenApply(s::publish),
reviewed.otherwise().thenApply(s::shelve));
});
Read it top to bottom:
planfollowed directly bycombinemakesplana spawning step: whatever branches its handler creates run in isolation, andcollectruns once every one of them is done. How many specialists run, and which, is decided by the model at run time — the topology does not know.repeatWhileis do-while: the critic always reads the first draft. The condition caps the loop, so a critic that is never satisfied cannot spend tokens forever.thenAwaitparks the instance until the editor's signal arrives. No worker is held while the editor reads; the deadline branch runsshelveUnansweredinstead and rejoins.MODEL_RETRYon every agent step: a rate-limited or timed-out model call is retried by the engine, with backoff, not by the agent.
The supervisor: a team shaped at run time§
public Brief plan(Brief b) {
String reply = model.complete(new Model.Prompt(SUPERVISOR, """
You lead a research team. Split the topic into at most %d subtasks, one per \
line, each written as "search: <question>" or "numbers: <question>". \
Reply with the lines only.""".formatted(MAX_SUBTASKS), b.topic()));
for (Task t : parseTasks(reply)) {
if (t.kind().equals("numbers")) {
Step.create(t).thenApply(this::analyse).thenApply(this::factCheck);
} else {
Step.create(t).thenApply(this::research);
}
}
return b;
}
Step.create(t) opens a branch whose whole context is the Task. A numbers subtask chains two
agents — the analyst's Finding is the fact checker's input — while a search subtask needs one.
The branches inherit plan's retry policy and queue. They are buffered on the worker and committed
with plan's report, so a supervisor that crashes half way through its loop has started no
specialist at all; the retried plan starts them all.
The specialists and the combine§
public Finding research(Task t) {
return new Finding(RESEARCHER, t.question(), model.complete(new Model.Prompt(RESEARCHER,
"You are a researcher. Answer the question in a short paragraph of facts.",
t.question())));
}
public Finding analyse(Task t) {
return new Finding(ANALYST, t.question(), model.complete(new Model.Prompt(ANALYST,
"You are an analyst. Answer with a numeric estimate and how you reached it.",
t.question())));
}
public Finding factCheck(Finding f) {
return new Finding(FACT_CHECKER, f.question(), model.complete(new Model.Prompt(FACT_CHECKER,
"You are a fact checker. Return the answer corrected where it is wrong, "
+ "unchanged where it is right.",
f.answer())));
}
public Brief collect(Brief base, List<Finding> findings) {
return new Brief(base.topic(), List.copyOf(findings), null, null, 0, null, null);
}
A specialist sees its subtask and nothing else — not the topic, not the other findings. That is the
point of running branches on isolated copies: what an agent knows is exactly what the workflow
handed it. The combine's parameters bind by type, so List<Finding> collects every branch's
result, whichever agent produced it, and Brief base is the context plan returned.
The critic loop§
/** Rewrites the draft against the critique; an approved draft passes through unchanged. */
public Brief revise(Brief b) {
if (isApproved(b)) return b;
return withDraft(b, model.complete(new Model.Prompt(WRITER,
"You are a writer. Revise the brief to address the critique.",
b.draft() + "\n\nCritique: " + b.critique())), b.revisions() + 1);
}
public boolean needsRevision(Brief b) {
return !isApproved(b) && b.revisions() < MAX_REVISIONS;
}
revise passes an approved draft through unchanged, which is what lets the loop body stay a plain
critique → revise while the condition decides whether to go round again.
The editor§
The editor is a person, so the workflow waits for them by signal. The demo plays the editor:
String id = client.start(spec, new ResearchTeam.Brief(topic));
awaitEditor(client, id, Duration.ofMinutes(10));
System.out.println("brief is with the editor; approving");
client.signal(id, ResearchTeam.APPROVAL, Map.of("decision", "approved"));
The signal's payload merges into the context, so decision arrives as a field of the Brief, and
approved routes on it. awaitEditor polls the instance's tokens until one is parked on
editor-approval; a real editor would see the brief in the portal or your own UI and signal from
there.
The model§
Agents call one interface. The team never sees which model is behind it:
public interface Model {
/**
* @param agent the calling agent's name, for routing, logging or a scripted reply
* @param system the agent's standing instructions
* @param user this turn's input
*/
record Prompt(String agent, String system, String user) {}
String complete(Prompt prompt);
}
Keep an implementation stateless. Whatever an agent must remember travels in the context, which is what makes a step re-runnable on another worker after a crash.
ScriptedModelgives each agent a canned reply built from its input. The critic rejects the first draft and approves the second, so one revision always runs. It is what the demo andResearchTeamTestuse.ClaudeModel, in the unpublished:example-claudemodule, calls the Claude Messages API.
@Override
public String complete(Prompt p) {
MessageCreateParams params = MessageCreateParams.builder()
.model(model)
.maxTokens(16000L)
.system(p.system())
.addUserMessage(p.user())
.fallbacksDefault()
.addBeta(AnthropicBeta.SERVER_SIDE_FALLBACK_2026_07_01)
.build();
BetaMessage reply;
try {
reply = client.beta().messages().create(params);
} catch (AnthropicServiceException e) {
int status = e.statusCode();
if (status == 408 || status == 409 || status == 429 || status >= 500) throw e;
throw new PermanentActivityException(p.agent() + ": " + e.getMessage(), e);
}
if (reply.stopReason().filter(BetaStopReason.REFUSAL::equals).isPresent()) {
throw new PermanentActivityException(p.agent() + ": the model declined this turn");
}
return reply.content().stream()
.flatMap(block -> block.text().stream())
.map(text -> text.text())
.collect(Collectors.joining());
}
The SDK's own retries are off, so the step's retry policy is the only retry loop. Which failures it retries is the adapter's call:
| Failure | Thrown as | What the engine does |
|---|---|---|
| 408, 409, 429, 5xx | the SDK's exception | retries the step with backoff |
| a refusal | — | the API retries it on a fallback model (fallbacks: "default") |
| a refusal that survives the fallback | PermanentActivityException |
fails the instance, no retry |
| any other 4xx | PermanentActivityException |
fails the instance, no retry |
The model defaults to claude-opus-5-5; set WIGGLE_CLAUDE_MODEL to change it.
OpenAiModel, in the unpublished:example-openaimodule, calls the OpenAI Responses API.
@Override
public String complete(Prompt p) {
ResponseCreateParams params = ResponseCreateParams.builder()
.model(model)
.maxOutputTokens(16000L)
.instructions(p.system())
.input(p.user())
.build();
Response reply;
try {
reply = client.responses().create(params);
} catch (OpenAIServiceException e) {
int status = e.statusCode();
if (status == 408 || status == 409 || status == 429 || status >= 500) throw e;
throw new PermanentActivityException(p.agent() + ": " + e.getMessage(), e);
}
List<ResponseOutputMessage.Content> content = reply.output().stream()
.flatMap(item -> item.message().stream())
.flatMap(message -> message.content().stream())
.toList();
if (content.stream().anyMatch(part -> part.refusal().isPresent())) {
throw new PermanentActivityException(p.agent() + ": the model declined this turn");
}
return content.stream()
.flatMap(part -> part.outputText().stream())
.map(text -> text.text())
.collect(Collectors.joining());
}
It maps failures the same way, except that OpenAI has no server-side fallback: a refusal part in the
reply fails the instance with PermanentActivityException. The model defaults to gpt-5.5; set
WIGGLE_OPENAI_MODEL to change it, and OPENAI_BASE_URL to point it at a compatible endpoint.
What durability buys an agent team§
| Event | Without a workflow engine | Here |
|---|---|---|
| a worker dies mid-call | the run is lost, or replayed from the start | only that agent's turn re-runs |
| the model rate-limits | every agent needs its own retry loop | one retry policy, on the step |
| five specialists | your own thread pool | branches, spread across every worker polling the queue |
| a human must sign off | a process holding a connection open | a parked instance and a signal, with a deadline |
| what did each agent see and say? | logs, if you wrote them | each step's input and output, recorded and shown in the portal |
Tests§
ResearchTeamTest runs the team against a real server with the scripted model. It covers:
- the publish path: three findings, one fact-checked, one revision, nine model calls;
- an editor who never answers shelves the brief at the deadline;
- a critic that never approves stops the loop at
MAX_REVISIONS; - a failed model call is retried by the step;
- the supervisor's reply parses leniently — list markers, unknown kinds, junk lines, a cap.