Skip to content

Add tool call confirmation to tool calling - #7157

Closed
tzolov wants to merge 2 commits into
spring-projects:mainfrom
tzolov:tool-call-decisions
Closed

tzolov wants to merge 2 commits into
spring-projects:mainfrom
tzolov:tool-call-decisions

Conversation

@tzolov

@tzolov tzolov commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Closes #6916
Related to #3813, #4871

Tools marked @Tool(requiresConfirmation = true), or selected by a ToolCallConfirmationPolicy, now pause the tool calling loop before any call of the reply runs. The reply goes back to the caller with the ids of the calls that await a decision. The application runs the calls with ToolCallDecisions and sends the tool results as its next request. The loop keeps no state between the pause and the resumption.

The change is additive: tools without the flag behave as before, and the new ToolCallingManager method is a default method.

Changes

spring-ai-model

  • @Tool.requiresConfirmation and ToolMetadata.requiresConfirmation()
  • ToolCallConfirmationPolicy: fromToolMetadata() (default), forTools(...), none(), or(...)
  • DefaultToolCallingManager: checks the policy before any call of the batch runs and throws ToolCallConfirmationRequiredException
  • ToolCallingManager.executeToolCalls(prompt, reply, decisions): allowed calls run, denied calls answer the model with the decision's message. Every call needs a decision, and allowed calls run only with tools the prompt carries.
  • Calls without ids, or sharing one, get ids of their own so decisions can name them

spring-ai-client-chat

  • ToolCallingAdvisor catches the exception and returns the paused reply, or a last chunk when streaming, with the awaiting ids in the response metadata

spring-ai-autoconfigure-model-tool

  • spring.ai.tools.confirmation.enabled and spring.ai.tools.confirmation.tools
  • ToolCallConfirmationPolicy beans are combined with or, so configuration can only add confirmations
  • Configuration metadata defaults for all spring.ai.tools.* properties

spring-ai-google-genai

  • GoogleGenAiToolCallingManager delegates the new method

Docs

  • New api/tools/tool-call-confirmation.adoc page, linked from the tool calling, ToolCallingAdvisor and Tool Search Tool pages

Notes

@tzolov tzolov added this to the 2.1.0-RC1 milestone Oct 14, 2026
@tzolov tzolov added enhancement New feature or request tool calling labels Oct 14, 2026
@ilayaperumalg ilayaperumalg self-assigned this Oct 14, 2026
Tools marked `@Tool(requiresConfirmation = true)`, or selected by a
ToolCallConfirmationPolicy, now pause the tool calling loop before
any call of the reply runs. The ToolCallingManager throws
ToolCallConfirmationRequiredException, and ToolCallingAdvisor returns
the reply with the ids of the calls that await a decision.

The application resumes by running the reply's calls with
ToolCallDecisions through the new ToolCallingManager overload, then
sends the tool results as its next request. Every call needs a
decision, and allowed calls run only with tools the prompt carries.
The loop keeps no state between the pause and the resumption.

Auto-configuration adds spring.ai.tools.confirmation.* properties
and picks up ToolCallConfirmationPolicy beans, combined so that they
can only add confirmations. Reference docs cover the feature.

Closes spring-projects#6916
See spring-projects#3813, spring-projects#4871

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
@tzolov
tzolov force-pushed the tool-call-decisions branch from 1e7916d to dc3b956 Compare October 14, 2026 11:04
@ilayaperumalg

Copy link
Copy Markdown
Member

The changes look good. It would be good to add an integration test which simulates the toolcall decision approval/denial

@dimitarproynov dimitarproynov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets add ITs, some with GoogleGenAiChatModel

* order the model asked for them. The policy sees the metadata of the tool the call
* would run with, or {@code null} when the manager has none for it.
*/
private List<String> awaitingConfirmation(AssistantMessage assistantMessage, List<ToolCallback> toolCallbacks) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be oldschool, but getAwaitingConfirmationIds is self-documenting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to getAwaitingConfirmationIds.

assistantMessage.getToolCalls().forEach(toolCall -> ids.add(toolCall.id()));
for (ToolCallDecision decision : decisions.decisions()) {
Assert.isTrue(ids.contains(decision.toolCallId()),
() -> "No tool call has the id '" + decision.toolCallId() + "'; the reply's calls: " + ids);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit "the reply's has toolCall ids: "

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded. Since decisions now name calls by key (see the mutate().toolCalls thread), it reads: No tool call has the key 'x'; the reply's tool call keys: [...].

List<ToolCallback> toolCallbacks) {
Assert.isTrue(hasDistinctToolCallIds(assistantMessage),
"Every tool call of the reply needs an id of its own to be decided on; resume with the reply the loop returned");
Set<String> ids = new LinkedHashSet<>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd call this assistantToolCallIds

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to assistantToolCallIds.

import org.springframework.util.Assert;

/**
* The decisions a person made on the tool calls of a reply, for a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "calls of an AssistantMessage"

: new AssistantMessage.ToolCall("call_" + UUID.randomUUID(), toolCall.type(), toolCall.name(),
toolCall.arguments()))
.toList();
return assistantMessage.mutate().toolCalls(toolCalls).build();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assistantMessage.mutate().toolCalls replaces the ToolCallPart with new tool calls converted into ToolCallPart, clearing any OpaquePayload and attributes, which is very destructive.

We should work with parts, not the legacy setters (toolCalls, content, reasoning, etc)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and I went one step further: the manager no longer touches the reply at all. Instead of giving id-less calls generated ids, decisions name calls by key: the call's id, or its position in the reply (#0, #1) when the model gave no id or a shared one.
ToolCallDecisions.toolCallKeys(message) computes them and is public, so an application can show each call next to its key.
The paused reply goes back to the caller exactly as the model produced it, parts, payloads and attributes included, and the tool results carry the model's ids. The Gemini IT runs with includeThoughts(true), so thought signatures across a pause are covered.

ToolCallConfirmationRequiredException ex) {
AssistantMessage toolCallsOnly = AssistantMessage.builder()
.content("")
.toolCalls(ex.getToolCallsMessage().getToolCalls())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Destructive use of legacy setter, use parts to preserve OpaquePayload and attributes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as above: payloads and attributes are kept.

List<String> awaiting = new ArrayList<>();
for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {
ToolCallback toolCallback = this.resolveToolCallback(toolCallbacks, toolCall.name());
ToolMetadata toolMetadata = (toolCallback != null) ? toolCallback.getToolMetadata() : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AugmentedToolCallback doesn't override getToolMetadata() and runs the default method:

default ToolMetadata getToolMetadata() {
	return ToolMetadata.builder().build();
}

which always returns empty metadata. Usage of AugmentedToolCallback will be broken

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. AugmentedToolCallback now delegates getToolMetadata() to the wrapped callback. This also fixes returnDirect for augmented tools, which was silently lost before. Test: shouldKeepTheDelegatesToolMetadata.

return tool != null && tool.returnDirect();
}

public static boolean getToolRequiresConfirmation(Method method) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs an @ since

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

return this;
}

public Builder requiresConfirmation(boolean requiresConfirmation) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs an @ since

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added on the builder method. The new record component is documented in the class Javadoc with its since version.

Comment on lines +219 to 223
chatClientResponse = paused(chatClientResponse, chatResponse, ex);
break;
}

fullTurnHistory = toolExecutionResult.conversationHistory();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two tool calls for this AssistantMessage reply;

The first one passes cleanly, result is written in fullTurnHistory.
The second one fails with ToolCallConfirmationRequiredException, fullTurnHistory is discarded

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within a single reply this can't happen: the manager evaluates the policy for the whole batch before running any call, so either every call of the reply runs or none does. callThatNeedsConfirmationPausesTheBatchBeforeAnythingRuns covers an un-gated call next to a gated one.

Across rounds you're right: if round 1 runs a tool and round 2 pauses, round 1's call and result live only in fullTurnHistory and don't reach the caller with the paused reply. I documented this in a new "Turns With Several Rounds" section, and placing the memory advisor inside the loop keeps those rounds, since it stores every round as it happens.
The IT withTheMemoryInsideTheLoopTheEarlierRoundsSurviveThePause covers that setup. Exposing the earlier rounds on the paused response is possible, but it would put tool results into response metadata that gets logged and forwarded, and memory outside the loop still wouldn't store them, so I'd rather leave that to the Session work.

Name tool calls by key instead of rewriting their ids. A key is the
call's id, or its position in the reply (`#0`, `spring-projects#1`) when the model
gave no id or a shared one. The paused reply now goes back to the
caller unchanged, with its parts, payloads and metadata intact, so
Gemini thought signatures survive a pause. ToolCallDecisions exposes
toolCallKeys(), and ToolCallConfirmationRequiredException carries
only the keys.

AugmentedToolCallback delegates getToolMetadata(), so wrapped tools
keep requiresConfirmation and returnDirect. GoogleGenAiChatModel maps
plain-text tool results, such as deny messages, under a "result" key
instead of failing to parse them as JSON.

Add integration tests for approval, denial, streaming and multi-round
turns, shared through AbstractToolCallConfirmationIT and run against
OpenAI, Gemini, Anthropic, Bedrock and Ollama. Document tool call keys
and turns with several rounds, add the missing `@since` tags, and
apply the naming suggestions from the review.

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
tzolov added a commit to spring-ai-community/spring-ai-session that referenced this pull request Oct 14, 2026
Spring AI's tool calling now pauses on tool calls that need a person's
confirmation (spring-projects/spring-ai#7157), so the session no longer
gates them itself. The SessionTurnAdvisor parks the turn when the loop
pauses, on a question that holds the paused reply and the ids that wait,
and resumes it when a request carries ToolCallDecisions: it records them,
runs the paused calls from the log through the loop's ToolCallingManager
and sends the results on. A call that does not wait is allowed, a
returnDirect result goes back without calling the model, and the results
for a model that gives no ids are stored under the ids the log holds. A
resumption that fails before any call ran can be decided on again; once
a call ran, the calls are answered with the failure.

ToolConfirmationAdvisor, SessionTurn, ToolConfirmationPolicy and
ToolConfirmation go. ToolConfirmations keeps the question and the answer
in one shape for the advisor and the agent's ToolConfirmationFeature.

SessionAdvisors.builder(sessionService) returns the turn, the memory and,
with a budget policy, the budget advisor as a List<Advisor> around the
ChatClient's own tool-calling loop; session(id) and decide(id, decisions)
fill a request's advisor context.

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
tzolov added a commit to spring-ai-community/spring-ai-session that referenced this pull request Oct 14, 2026
A ChatClient call can carry a run id, its idempotency key
(SessionTurnAdvisor.RUN_ID_CONTEXT_KEY). The turn records it, and the
memory advisor's new default, RunScopedSessionEventIdGenerator, derives
each event id from the session, the run, how the turn began, the round of
the tool loop and the slot in the round, never from the message text. A
replay of the run writes nothing twice, while identical messages in two
runs, or identical tool calls without ids in one turn, are all stored.
Without a run id the ids stay random. IdempotentSessionEventIdGenerator is
deprecated, since its content-derived ids drop repeated messages.

When nothing was stored for a request, REQUEST_EVENT_ID_CONTEXT_KEY is now
left out instead of set to null, which broke the model call advisor's
copy of the context.

Tool confirmations decide on calls by key, as spring-projects/spring-ai#7157
now names them: the call's id, or its position when it has none. The paused
reply is the model's, so the results need no id mapping.

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
@tzolov

tzolov commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@ilayaperumalg , @dimitarproynov . Thanks for the feedback. Just pushed a commit addressing the raised conserns.

  • Integration tests: AbstractToolCallConfirmationIT in spring-ai-test covers approval, denial, a streamed pause with a streamed resume, and a multi-round turn with the memory advisor inside the loop. Each scenario does a real second request with the tool results. Subclasses run it against OpenAI, Gemini (3 Flash with includeThoughts), Anthropic (Sonnet 4.6), Bedrock and Ollama (qwen2.5:3b). All pass (though have not run the bedrock one)

The Gemini run surfaced a pre-existing bug: GoogleGenAiChatModel parsed every tool result as JSON, so plain-text results such as a deny message or a tool error message failed before the request was sent. It now maps non-JSON results under a result key.

The other notable change is that the manager no longer rewrites tool call ids, see the thread on DefaultToolCallingManager.

@ilayaperumalg

Copy link
Copy Markdown
Member

Squashed, rebased and merged via 24c782a

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request tool calling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Using function tools with human in the loop approvals

3 participants