alibaba/spring-ai-alibaba · error · RuntimeException
Failed to execute agent tool '%s' (parentThreadId=%s, input=
Error message
Failed to execute agent tool '%s' (parentThreadId=%s, input=%s): sub-agent returned no assistant message. Last message type:
What it means
AgentTool.executeAgent requires the sub-agent's final state to contain messages whose last entry is an AssistantMessage (the sub-agent's answer). When the sub-agent produced messages but the last one is a different type (e.g. UserMessage, ToolResponseMessage), the tool throws 'sub-agent returned no assistant message. Last message type: <type>'. This means the sub-agent finished without producing a model answer.
Source
Thrown at spring-ai-alibaba-agent-framework/src/main/java/com/alibaba/cloud/ai/graph/agent/AgentTool.java:244
}
}
catch (GraphRunnerException e) {
throw buildExecutionException(actualInput, parentConfigOpt.orElse(null),
"sub-agent invocation failed: " + e.getMessage(), e);
}
catch (RuntimeException e) {
throw buildExecutionException(actualInput, parentConfigOpt.orElse(null),
"sub-agent invocation failed: " + e.getMessage(), e);
}
Optional<List> messages = resultState.flatMap(overAllState -> overAllState.value("messages", List.class));
if (messages.isPresent()) {
@SuppressWarnings("unchecked")
List<Message> messageList = (List<Message>) messages.get();
if (!messageList.isEmpty() && messageList.get(messageList.size() - 1) instanceof AssistantMessage assistantMessage) {
return assistantMessage;
}
throw buildExecutionException(actualInput, parentConfigOpt.orElse(null),
"sub-agent returned no assistant message. Last message type: "
+ (messageList.isEmpty() ? "<empty>" : messageList.get(messageList.size() - 1).getMessageType()),
null);
}
throw buildExecutionException(actualInput, parentConfigOpt.orElse(null),
"sub-agent returned no messages", null);
}
private RuntimeException buildExecutionException(String actualInput, RunnableConfig parentConfig, String detail,
Throwable cause) {
String threadId = parentConfig != null ? parentConfig.threadId().orElse("<no-thread-id>") : "<standalone>";
String message = String.format("Failed to execute agent tool '%s' (parentThreadId=%s, input=%s): %s",
agent.name(), threadId, actualInput, detail);
return cause == null ? new RuntimeException(message) : new RuntimeException(message, cause);
}
/**View on GitHub (pinned to f82da0b50f)
Solutions
- Check the reported last message type in the error — if ToolResponseMessage, ensure the sub-agent graph loops back to the LLM node after tool execution instead of ending.
- Verify the sub-agent's max-iterations/tool-call limits are high enough for the task so the loop reaches a final AssistantMessage.
- Inspect for human-in-the-loop interrupts: either complete the interrupt and resume, or remove interrupt nodes from agents used as tools.
- Confirm the sub-agent's state key strategy appends to 'messages' and that the model node's output is stored as AssistantMessage in that key.
- If the list is '<empty>', check that the sub-agent actually received the UserMessage and that its graph has a runnable path from the entry node.
Example fix
// before: graph ends right after tool node
.addEdge("toolNode", END)
// after: loop back to LLM so a final AssistantMessage is produced
.addConditionalEdges("toolNode",
(state) -> state.value("messages").map(m -> !m.isEmpty()).orElse(false),
Map.of("continue", "llmNode", "end", END)) Defensive patterns
Strategy: type-guard
Validate before calling
List<Message> msgs = (List<Message>) finalState.value("messages", List.class).orElse(List.of());
if (msgs.isEmpty() || !(msgs.get(msgs.size() - 1) instanceof AssistantMessage)) {
throw new IllegalStateException("Sub-agent did not end with an AssistantMessage");
} Type guard
static boolean endsWithAssistantMessage(OverAllState state) {
return state.value("messages", List.class)
.map(m -> !m.isEmpty() && m.get(m.size() - 1) instanceof AssistantMessage)
.orElse(false);
} Try / catch
try {
AssistantMessage reply = agentToolExecutor.executeAgent(input, toolContext);
}
catch (RuntimeException e) {
if (e.getMessage() != null && e.getMessage().contains("no assistant message")) {
// inspect last message type in message, decide whether to resume or fail the parent step
} else {
throw e;
}
} Prevention
- Route tool nodes back to the LLM node instead of END so the run ends with a model answer.
- Set sufficient max-iterations for tool-calling sub-agents.
- Avoid interrupt()/human-in-the-loop nodes in agents exposed as tools.
- Confirm the model node writes its output into the 'messages' key.
When it happens
Trigger: Sub-agent run ended after a tool/interrupt node without a final model response; last message is a ToolResponseMessage because the loop stopped before the LLM summarized; an interrupt/human-in-the-loop node ended execution; the sub-agent's graph routes to END directly after a non-model node; messages list is present but empty ('<empty>').
Common situations: Sub-agent configured with tool-calling loop that hit a max-iterations guard; interrupt() used mid-graph so state ends with a tool/user message; wrong output key strategy so assistant output never lands in 'messages'; sub-agent model refused/errored and only the user message remains.
Related errors
- Failed to execute agent tool '%s' (parentThreadId=%s, input=
- Failed to execute agent tool '%s' (parentThreadId=%s, input=
- extra tool param should be map
- TOOL_PARAMS_MISSING
- TOOL_EXECUTION_ERROR
AI-assisted analysis of alibaba/spring-ai-alibaba@f82da0b50f (2026-09-09).
Data as JSON: /api/errors/c5326f83b2726376.
Report an issue: GitHub.