spring-projects/spring-ai · error · java.lang.IllegalStateException

Currently only one tool call is supported per message!

Error message

Currently only one tool call is supported per message!

What it means

DeepSeekStreamFunctionCallingHelper merges successive streaming chunks that carry partial tool-call deltas. Because DeepSeek streams one tool call per chunk, the merge logic assumes at most one tool call per message; receiving a chunk message containing more than one tool call breaks the incremental assembly and triggers this IllegalStateException.

Source

Thrown at models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekStreamFunctionCallingHelper.java:103

		String name = (current.name() != null ? current.name() : (previous != null ? previous.name() : null));
		String toolCallId = (current.toolCallId() != null ? current.toolCallId()
				: (previous != null ? previous.toolCallId() : null));

		Boolean prefix = (current.prefix() != null ? current.prefix() : (previous != null ? previous.prefix() : null));
		String reasoningContent = (current.reasoningContent() != null ? current.reasoningContent()
				: (previous != null ? previous.reasoningContent() : null));

		List<ToolCall> toolCalls = new ArrayList<>();
		ToolCall lastPreviousTooCall = null;
		if (previous != null && !CollectionUtils.isEmpty(previous.toolCalls())) {
			lastPreviousTooCall = previous.toolCalls().get(previous.toolCalls().size() - 1);
			if (previous.toolCalls().size() > 1) {
				toolCalls.addAll(previous.toolCalls().subList(0, previous.toolCalls().size() - 1));
			}
		}
		if (!CollectionUtils.isEmpty(current.toolCalls())) {
			if (current.toolCalls().size() > 1) {
				throw new IllegalStateException("Currently only one tool call is supported per message!");
			}
			var currentToolCall = current.toolCalls().iterator().next();
			if (StringUtils.hasText(currentToolCall.id())) {
				if (lastPreviousTooCall != null) {
					toolCalls.add(lastPreviousTooCall);
				}
				toolCalls.add(currentToolCall);
			}
			else {
				toolCalls.add(merge(lastPreviousTooCall, currentToolCall));
			}
		}
		else {
			if (lastPreviousTooCall != null) {
				toolCalls.add(lastPreviousTooCall);
			}
		}
		return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls, prefix, reasoningContent);

View on GitHub (pinned to 98a7beda4f)

Solutions

  1. Restrict the request to a single tool call per turn (set tool_choice to a specific function or parallel_tool_calls=false if supported)
  2. Upgrade spring-ai-deepseek to a version whose stream helper handles multiple tool-call deltas
  3. Aggregate tool calls manually from the raw chunks instead of relying on the merge helper

Example fix

// before
ChatCompletionRequest req = ChatCompletionRequest.builder().tools(tools).build(); // model may emit multiple calls
// after
ChatCompletionRequest req = ChatCompletionRequest.builder()
    .tools(tools)
    .toolChoice(ChatCompletionRequest.ToolChoice.of("my_function"))
    .build();
Defensive patterns

Strategy: try-catch

Try / catch

try { return streamHelper.merge(previous, current); } catch (IllegalStateException e) { if (e.getMessage().contains("only one tool call")) { /* handle chunk with multiple tool calls separately */ } throw e; }

Prevention

When it happens

Trigger: Processing a DeepSeek chat-completion stream where a ChatCompletionChunk message contains toolCalls().size() > 1 — e.g. the model emits multiple parallel tool calls inside a single streamed message while the helper merges it with previous state.

Common situations: Prompts that request multiple tool/function invocations in one turn; server-side changes in DeepSeek streaming format emitting batched tool calls; non-DeepSeek-compatible proxies returning OpenAI-style multi-tool chunks.

Related errors


AI-assisted analysis of spring-projects/spring-ai@98a7beda4f (2026-09-11). Data as JSON: /api/errors/bf7a3756d7c9af52. Report an issue: GitHub.