{"record":{"id":"91f4554361d87871","repo":"earendil-works/pi","slug":"cannot-continue-from-message-role-assistant","errorCode":null,"errorMessage":"Cannot continue from message role: assistant","messagePattern":"Cannot continue from message role: assistant","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/agent/src/agent-loop.ts","lineNumber":75,"sourceCode":" * Continue an agent loop from the current context without adding a new message.\n * Used for retries - context already has user message or tool results.\n *\n * **Important:** The last message in context must convert to a `user` or `toolResult` message\n * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.\n * This cannot be validated here since `convertToLlm` is only called once per turn.\n */\nexport function agentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\tstreamFn: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst stream = createAgentStream();\n\n\tvoid runAgentLoopContinue(\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t).then((messages) => {\n\t\tstream.end(messages);\n\t});\n\n\treturn stream;\n}","sourceCodeStart":57,"sourceCodeEnd":93,"githubUrl":"https://github.com/earendil-works/pi/blob/4af9d21d3b4d664e4a29fcabfec85171077248e3/packages/agent/src/agent-loop.ts#L57-L93","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Append a user or toolResult message to the context before continuing, or use agentLoop([newUserMessage], ...) to add one properly.","If you meant 'make the model say more', send a short follow-up user instruction instead of resuming from the assistant turn.","In retry code, check the last message role and skip the retry when it is 'assistant'."],"exampleFix":"// before\nconst stream = agentLoopContinue(context, config, signal, streamFn); // last message is assistant -> throws\n\n// after\ncontext.messages.push({ role: \"user\", content: [{ type: \"text\", text: \"continue\" }], timestamp: Date.now() });\nconst stream = agentLoopContinue(context, config, signal, streamFn);","handlingStrategy":"validation","validationCode":"const last = context.messages[context.messages.length - 1];\nif (!last || last.role === \"assistant\") {\n  context.messages.push(newUserMessage); // or call agentLoop with a prompt\n}\nconst stream = agentLoopContinue(context, config, signal, streamFn);","typeGuard":"function endsOnContinuableRole(messages: AgentMessage[]): boolean {\n  const last = messages[messages.length - 1];\n  return last !== undefined && last.role !== \"assistant\";\n}","tryCatchPattern":"try {\n  const stream = agentLoopContinue(context, config, signal, streamFn);\n} catch (err) {\n  if (err instanceof Error && err.message.includes(\"role: assistant\")) {\n    // append a user/toolResult message, or prompt instead of continuing\n  } else {\n    throw err;\n  }\n}","preventionTips":["Map regenerate/continue UI actions to a new user prompt, not a resume from an assistant turn.","Check the last message role before every continue call.","Remember custom AgentMessage roles must map to user/toolResult via convertToLlm - the loop can only validate the recorded role."],"tags":["agent-loop","precondition","message-role","assistant"],"backgroundTag":"invalid-last-message-role","analyzedSha":"4af9d21d3b4d664e4a29fcabfec85171077248e3","analyzedAt":"2026-08-24T13:07:14.692Z","schemaVersion":2},"datasetVersion":"2026-08-24T17:17:21.512Z"}