Writing a multi-turn agent
A single-turn agent answers one input and returns one output — see
Agents for that process(input) contract. A multi-turn
agent holds a conversation: a simulated user talks to your agent across several
turns, and the agent is scored on how the whole exchange goes.
This guide explains how Spec27 calls a multi-turn agent, how to keep conversation state between turns, how a conversation ends, and how to handle the edge cases that come up when you connect a real chat service. For why you'd run a multi-turn evaluation and how it's scored, see Goal-based multi-turn evaluation and Red-team multi-turn evaluation.
How a multi-turn run calls your agent
A multi-turn run is a loop, and your agent is one side of it. The other side is a user simulator that plays the human. For each turn, Spec27:
- Starts with the user. The conversation is always user-initiated — the simulator speaks first. Your agent never opens the conversation.
- Calls
process(input)once per turn, whereinputis that turn's user message text. One call returns one assistant turn. - Reuses the same runtime for the whole conversation. Each conversation gets its own runtime, and any module-level state you set survives from one turn to the next within that conversation.
- Keeps going up to the max turns, or until the simulator ends the conversation (see Ending the conversation). The max-turns limit is set on the specification — for Goal Gold Team it defaults to 6 and can be set between 1 and 10.
The entry point is the same process(input) you use for a single-turn agent; what
makes an agent multi-turn is that it remembers earlier turns.
Keeping state across turns
Because the runtime is reused across turns, you keep conversation state in
module-level variables — declared outside process — and read or update them on
each call. The most common pattern is a running message history:
let messages = [];
export default async function process(input) {
messages.push({ role: "user", content: input });
const reply = await callYourService(messages);
messages.push({ role: "assistant", content: reply });
return reply;
}
The history starts empty for each conversation, then accumulates turn by turn, so
your service sees the full context. Commit state only after a successful turn —
update messages once you have a good reply, not before — so that if a turn fails,
the earlier history is left intact.
The OpenRouter Multi-Turn Chat Completion Agent
keeps a messages array exactly this way, and the
OpenDialog Multi-Turn Agent keeps a
session id across turns. Copy either from the registry as a starting point.
Ending the conversation
The user simulator decides when the conversation ends, not your agent. It ends the run when it has reached the goal (or given up), and it always ends once the max-turns limit is hit. Your agent does not need to do anything special to finish.
Your agent can signal that it wants to stop — for example by replying with something like "I'll end the conversation here" — but that is just text. The simulator may or may not honor it. Treat it as a hint, not a guarantee.
Returning an empty reply (null, undefined, or "") is treated as a valid
empty turn: it is recorded and the conversation continues. It does not stop the
run.
Handling the edge cases
Real chat services don't always behave like a clean request/response API. These are
the cases to handle in your process function.
The service greets first
Some services send an automatic greeting when a session opens ("Hi, how can I help?"). This greeting comes from the backing service itself — triggered by session initialization, not by any message from the user simulator. Your agent receives it before it has forwarded the simulator's input to the service.
Your agent should ignore it. Drain and discard the opening greeting, then return only the reply to the user simulator's actual input. Otherwise the greeting gets scored as your agent's answer to the first user message.
The service sends several messages per turn
A turn is one process(input) call and one returned string. If your service replies
with several messages for a single user message (for example a short acknowledgement
followed by the real answer), Spec27 does not combine them for you.
Your agent should pool them. Wait until the service has finished sending messages for the turn, concatenate them into a single string, and return that. The OpenDialog Multi-Turn Agent does this — it joins the text of every message in the reply into one string.
The service errors or stalls
If a turn fails, how you end it matters:
- Returning nothing (an empty reply) is a normal empty turn — the conversation continues.
- Throwing an error ends the conversation immediately. The turns that already happened are kept and the partial transcript is still passed to the judge, so the result reflects how far the conversation got before it broke.
So, when your service errors or returns nothing:
- Retry inside
processif it might help (for example a transient network failure). Retrying is your agent's responsibility — Spec27 does not retry a failed turn for you. - Throw when you cannot recover. A thrown error is the clean way to end the conversation; the partial transcript is preserved for scoring.
- Set your own request timeout with an
AbortControllerso a hung call surfaces as a normal error you can throw. If a turn hangs long enough to hit the sandbox timeout instead, the runtime is torn down and the conversation stops — you lose the chance to handle it yourself.
What's not yet supported
These behaviors are not provided by the platform today. Where there's a workaround, it's noted above as something your agent does itself:
- Automatic message pooling. Spec27 does not merge multiple service messages into one turn — your agent must pool and concatenate them (see above).
- Platform retry of a failed turn. A thrown error ends the conversation; there is
no automatic retry or resume of that turn. Retry inside
processif you need it. - Recovering from a sandbox timeout. If a turn exceeds the sandbox timeout, the runtime is torn down and the conversation stops. Use an in-agent timeout to stay in control.