{"record":{"id":"d4fc5e2198b62238","repo":"spring-projects/spring-ai","slug":"tool-call-limit-exceeded-dynamic-message-tool-na","errorCode":null,"errorMessage":"Tool call limit exceeded (dynamic message: tool name and limit)","messagePattern":"Tool call limit exceeded \\(dynamic message: tool name and limit\\)","errorType":"exception","errorClass":"ToolCallLimitExceededException","httpStatus":null,"severity":"error","filePath":"spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java","lineNumber":262,"sourceCode":"\n\t\t\ttotalToolCallCount++;\n\t\t\tint toolCallCount = toolCallCounts.merge(toolName, 1, Integer::sum);\n\n\t\t\tToolCallLimits.Breach limitBreach = this.toolCallLimits.check(toolName, toolCallCount, totalToolCallCount);\n\t\t\tif (limitBreach != null) {\n\t\t\t\ttoolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, limitBreach.message()));\n\n\t\t\t\tif (this.toolCallLimits.onLimitExceeded() == ToolCallLimitBehavior.THROW) {\n\t\t\t\t\tToolResponseMessage partialToolResponseMessage = ToolResponseMessage.builder()\n\t\t\t\t\t\t.responses(toolResponses)\n\t\t\t\t\t\t.build();\n\t\t\t\t\tList<Message> partialConversationHistory = buildConversationHistoryAfterToolExecution(\n\t\t\t\t\t\t\tprompt.getInstructions(), assistantMessage, partialToolResponseMessage);\n\t\t\t\t\tToolExecutionResult partialToolExecutionResult = ToolExecutionResult.builder()\n\t\t\t\t\t\t.conversationHistory(partialConversationHistory)\n\t\t\t\t\t\t.returnDirect(Objects.requireNonNullElse(returnDirect, false))\n\t\t\t\t\t\t.build();\n\t\t\t\t\tthrow new ToolCallLimitExceededException(limitBreach.toolName(), limitBreach.limit(),\n\t\t\t\t\t\t\tpartialToolExecutionResult);\n\t\t\t\t}\n\n\t\t\t\t// ToolCallLimitBehavior.RETURN_ERROR_RESPONSE: skip invoking this tool\n\t\t\t\t// call but keep processing the rest of the batch.\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tString toolInputArguments = toolCall.arguments();\n\n\t\t\t// Handle the possible null parameter situation in streaming mode.\n\t\t\tfinal String finalToolInputArguments;\n\t\t\tif (!StringUtils.hasText(toolInputArguments)) {\n\t\t\t\tif (logger.isWarnEnabled()) {\n\t\t\t\t\tlogger.warn(\"Tool call arguments are null or empty for tool: \" + toolName\n\t\t\t\t\t\t\t+ \". Using empty JSON object as default.\");\n\t\t\t\t}\n\t\t\t\tfinalToolInputArguments = \"{}\";","sourceCodeStart":244,"sourceCodeEnd":280,"githubUrl":"https://github.com/spring-projects/spring-ai/blob/98a7beda4f29d80a71c5837eb4053b03a93a46f7/spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java#L244-L280","documentation":"Spring AI's DefaultToolCallingManager enforces a per-tool call limit (ToolCallingChatOptions maxCalls). When ToolCallLimitBehavior.THROW_EXCEPTION is configured and a tool exceeds its allowed number of invocations, the manager throws ToolCallLimitExceededException carrying the tool name, the limit, and a partial ToolExecutionResult built from the conversation history up to that point. It exists to stop runaway LLM tool-calling loops.","triggerScenarios":"Calling ChatClient/ChatModel with ToolCallingChatOptions where a tool limit is registered via toolCallLimit(toolName, maxCalls) with behavior THROW_EXCEPTION, and the model then requests that tool more than maxCalls times during internalToolExecutionResult processing; executeToolCall detects the breach via limitBreach and throws.","commonSituations":"LLMs entering tool-calling loops (repeatedly calling a search or fetch tool because results don't satisfy them), setting maxCalls too low for legitimate multi-step workflows, or agents with recursive/self-referencing tools.","solutions":["Raise the per-tool call limit (e.g. toolCallLimit(\"myTool\", 10)) so legitimate workflows complete","Change the limit behavior to ToolCallLimitBehavior.RETURN_ERROR_RESPONSE so the model receives an error response instead of an exception","Catch ToolCallLimitExceededException and use getResult().conversationHistory() from the partial result to continue or summarize","Improve tool responses/descriptions so the model stops re-invoking the tool"],"exampleFix":"// before\nChatOptions options = ToolCallingChatOptions.builder()\n    .toolCallbacks(tools)\n    .toolCallLimit(\"search\", 2, ToolCallLimitBehavior.THROW_EXCEPTION)\n    .build();\n// after\nChatOptions options = ToolCallingChatOptions.builder()\n    .toolCallbacks(tools)\n    .toolCallLimit(\"search\", 10, ToolCallLimitBehavior.RETURN_ERROR_RESPONSE)\n    .build();","handlingStrategy":"try-catch","validationCode":"// Before calling the model, ensure limits are sensible\nToolCallingChatOptions opts = ...;\nif (opts.getToolCallLimit() != null && opts.getToolCallLimit() < expectedMaxToolCalls) {\n    throw new IllegalArgumentException(\"tool call limit too low for this workflow\");\n}","typeGuard":null,"tryCatchPattern":"try {\n    ChatResponse resp = chatModel.call(prompt);\n} catch (ToolCallLimitExceededException e) {\n    // inspect e.getToolName(), e.getLimit(); continue from e.getResult().conversationHistory()\n    logger.warn(\"Tool {} hit call limit {}\", e.getToolName(), e.getLimit());\n}","preventionTips":["Set per-tool limits generously (workflow max steps + margin)","Prefer ToolCallLimitBehavior.RETURN_ERROR_RESPONSE in production agents","Monitor how many tool calls each conversation actually consumes and tune limits"],"tags":["spring-ai","tool-calling","rate-limit","agent-loop"],"backgroundTag":"rate-limit-exceeded","analyzedSha":"98a7beda4f29d80a71c5837eb4053b03a93a46f7","analyzedAt":"2026-09-11T14:15:49.441Z","contentChangedAt":"2026-09-11T14:15:49.441Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}