{"record":{"id":"edb460eadc5ad4c3","repo":"alibaba/spring-ai-alibaba","slug":"less-than-2-messages-in-state-when-last-message-is","errorCode":null,"errorMessage":"Less than 2 messages in state when last message is ToolResponseMessage","messagePattern":"Less than 2 messages in state when last message is ToolResponseMessage","errorType":"exception","errorClass":"RuntimeException","httpStatus":null,"severity":"critical","filePath":"spring-ai-alibaba-agent-framework/src/main/java/com/alibaba/cloud/ai/graph/agent/ReactAgent.java","lineNumber":805,"sourceCode":"\t\t\tif (messages.isEmpty()) {\n\t\t\t\tlogger.warn(\"No messages found in state when routing from model to tools\");\n\t\t\t\treturn endDestination;\n\t\t\t}\n\t\t\tMessage lastMessage = messages.get(messages.size() - 1);\n\n\t\t\t// 1. Check the last message type\n\t\t\tif (lastMessage instanceof AssistantMessage assistantMessage) {\n\t\t\t\t// 2. If last message is AssistantMessage\n\t\t\t\tif (assistantMessage.hasToolCalls()) {\n\t\t\t\t\treturn AGENT_TOOL_NAME;\n\t\t\t\t} else {\n\t\t\t\t\treturn endDestination;\n\t\t\t\t}\n\t\t\t} else if (lastMessage instanceof ToolResponseMessage) {\n\t\t\t\t// 3. If last message is ToolResponseMessage\n\t\t\t\tif (messages.size() < 2) {\n\t\t\t\t\t// Should not happen in a valid ReAct loop, but as a safeguard.\n\t\t\t\t\tthrow new RuntimeException(\"Less than 2 messages in state when last message is ToolResponseMessage\");\n\t\t\t\t}\n\n\t\t\t\tMessage secondLastMessage = messages.get(messages.size() - 2);\n\t\t\t\tif (secondLastMessage instanceof AssistantMessage) {\n\t\t\t\t\tAssistantMessage assistantMessage = (AssistantMessage) secondLastMessage;\n\t\t\t\t\tToolResponseMessage toolResponseMessage = (ToolResponseMessage) lastMessage;\n\n\t\t\t\t\tif (assistantMessage.hasToolCalls()) {\n\t\t\t\t\t\tSet<String> requestedToolIds = assistantMessage.getToolCalls().stream()\n\t\t\t\t\t\t\t\t.map(AssistantMessage.ToolCall::id)\n\t\t\t\t\t\t\t\t.collect(java.util.stream.Collectors.toSet());\n\n\t\t\t\t\t\tSet<String> executedToolIds = toolResponseMessage.getResponses().stream()\n\t\t\t\t\t\t\t\t.map(ToolResponseMessage.ToolResponse::id)\n\t\t\t\t\t\t\t\t.collect(java.util.stream.Collectors.toSet());\n\n\t\t\t\t\t\tif (executedToolIds.containsAll(requestedToolIds)) {\n\t\t\t\t\t\t\treturn modelDestination; // All requested tools were executed or responded","sourceCodeStart":787,"sourceCodeEnd":823,"githubUrl":"https://github.com/alibaba/spring-ai-alibaba/blob/f82da0b50f35744c13968191be2b1cd2452ef550/spring-ai-alibaba-agent-framework/src/main/java/com/alibaba/cloud/ai/graph/agent/ReactAgent.java#L787-L823","documentation":"In ReactAgent.makeModelToTools routing (set up by setupToolRouting), when the last message in state is a ToolResponseMessage the code expects the preceding AssistantMessage tool-call message to exist. If fewer than 2 messages are present it throws RuntimeException, as this indicates corrupted state that cannot occur in a valid ReAct loop.","triggerScenarios":"Resuming a graph/agent from a persisted checkpoint where the message list was truncated or hand-crafted to end with a ToolResponseMessage without its AssistantMessage tool-call predecessor.","commonSituations":"Manually seeding conversation state (e.g. only a tool response for testing); checkpoint/serialization bugs dropping earlier messages; state surgery that removes the assistant tool-call message.","solutions":["Fix the state so a ToolResponseMessage is always preceded by the AssistantMessage containing the matching ToolCall.","Do not manually trim messages to fewer than the assistant-call + tool-response pair when resuming checkpoints.","Check the saver/checkpoint implementation for message truncation (e.g. max-message limits cutting the pair in half).","Guard resume paths: validate the message tail before feeding state into the agent."],"exampleFix":"// before\nstate.put(\"messages\", List.of(toolResponse));\n// after\nstate.put(\"messages\", List.of(assistantWithToolCall, toolResponse));","handlingStrategy":"validation","validationCode":"List<Message> msgs = (List<Message>) state.get(\"messages\");\nMessage last = msgs.get(msgs.size() - 1);\nif (last instanceof ToolResponseMessage && (msgs.size() < 2 || !(msgs.get(msgs.size() - 2) instanceof AssistantMessage)))\n    throw new IllegalStateException(\"Invalid message tail: ToolResponseMessage without preceding AssistantMessage\");","typeGuard":"static boolean validMessageTail(List<Message> m) {\n    if (m == null || m.isEmpty()) return true;\n    return !(m.get(m.size() - 1) instanceof ToolResponseMessage)\n        || (m.size() >= 2 && m.get(m.size() - 2) instanceof AssistantMessage);\n}","tryCatchPattern":"try { graph.resume(state); } catch (RuntimeException e) { if (e.getMessage().contains(\"Less than 2 messages\")) { log.error(\"Corrupt checkpoint state: rebuild from last valid checkpoint\"); } throw e; }","preventionTips":["Never hand-edit or truncate persisted message state.","Verify checkpoint savers don't trim assistant tool-call messages.","Validate message-tail invariants before resuming from any checkpoint."],"tags":["state-management","react-loop","invariant-violation"],"backgroundTag":"internal-invariant-violation","analyzedSha":"f82da0b50f35744c13968191be2b1cd2452ef550","analyzedAt":"2026-09-09T15:32:42.421Z","contentChangedAt":"2026-09-09T15:32:42.421Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}