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

  1. 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.
  2. Verify the sub-agent's max-iterations/tool-call limits are high enough for the task so the loop reaches a final AssistantMessage.
  3. Inspect for human-in-the-loop interrupts: either complete the interrupt and resume, or remove interrupt nodes from agents used as tools.
  4. 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.
  5. 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

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


AI-assisted analysis of alibaba/spring-ai-alibaba@f82da0b50f (2026-09-09). Data as JSON: /api/errors/c5326f83b2726376. Report an issue: GitHub.