earendil-works/pi · error · Error

Cannot continue from message role: assistant

Error message

Cannot continue from message role: assistant

What it means

agentLoopContinue() requires the last message in context to convert to a user or toolResult message via convertToLlm, because LLM providers reject conversations that end with the assistant's own message (there is nothing new to answer). Since convertToLlm only runs once per turn, the loop pre-checks the recorded role and throws synchronously when it is 'assistant'. This is a guard against double-continuing: the model already produced the final assistant turn for the current input.

Source

Thrown at packages/agent/src/agent-loop.ts:75

 * Continue an agent loop from the current context without adding a new message.
 * Used for retries - context already has user message or tool results.
 *
 * **Important:** The last message in context must convert to a `user` or `toolResult` message
 * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
 * This cannot be validated here since `convertToLlm` is only called once per turn.
 */
export function agentLoopContinue(
	context: AgentContext,
	config: AgentLoopConfig,
	signal: AbortSignal | undefined,
	streamFn: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> {
	if (context.messages.length === 0) {
		throw new Error("Cannot continue: no messages in context");
	}

	if (context.messages[context.messages.length - 1].role === "assistant") {
		throw new Error("Cannot continue from message role: assistant");
	}

	const stream = createAgentStream();

	void runAgentLoopContinue(
		context,
		config,
		async (event) => {
			stream.push(event);
		},
		signal,
		streamFn,
	).then((messages) => {
		stream.end(messages);
	});

	return stream;
}

View on GitHub (pinned to 4af9d21d3b)

Solutions

  1. Append a user or toolResult message to the context before continuing, or use agentLoop([newUserMessage], ...) to add one properly.
  2. If you meant 'make the model say more', send a short follow-up user instruction instead of resuming from the assistant turn.
  3. In retry code, check the last message role and skip the retry when it is 'assistant'.

Example fix

// before
const stream = agentLoopContinue(context, config, signal, streamFn); // last message is assistant -> throws

// after
context.messages.push({ role: "user", content: [{ type: "text", text: "continue" }], timestamp: Date.now() });
const stream = agentLoopContinue(context, config, signal, streamFn);
Defensive patterns

Strategy: validation

Validate before calling

const last = context.messages[context.messages.length - 1];
if (!last || last.role === "assistant") {
  context.messages.push(newUserMessage); // or call agentLoop with a prompt
}
const stream = agentLoopContinue(context, config, signal, streamFn);

Type guard

function endsOnContinuableRole(messages: AgentMessage[]): boolean {
  const last = messages[messages.length - 1];
  return last !== undefined && last.role !== "assistant";
}

Try / catch

try {
  const stream = agentLoopContinue(context, config, signal, streamFn);
} catch (err) {
  if (err instanceof Error && err.message.includes("role: assistant")) {
    // append a user/toolResult message, or prompt instead of continuing
  } else {
    throw err;
  }
}

Prevention

When it happens

Trigger: Calling agentLoopContinue when the transcript's last message is an assistant message: after a completed run where the model answered without tool calls, after manually appending an assistant message, or from retry logic that continues a turn that ended on stopReason 'error' or 'aborted' with an assistant message already streamed.

Common situations: Chat UIs mapping a regenerate/continue button to a continue call after the agent already replied; retry-after-error wrappers; tests seeding transcripts that end with an assistant message.

Related errors


AI-assisted analysis of earendil-works/pi@4af9d21d3b (2026-08-24). Data as JSON: /api/errors/91f4554361d87871. Report an issue: GitHub.