Repository navigation
Conversation
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>
1e7916d to
dc3b956
Compare
|
The changes look good. It would be good to add an integration test which simulates the toolcall decision approval/denial |
dimitarproynov
left a comment
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
I may be oldschool, but getAwaitingConfirmationIds is self-documenting.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
nit "the reply's has toolCall ids: "
There was a problem hiding this comment.
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<>(); |
There was a problem hiding this comment.
nit: I'd call this assistantToolCallIds
There was a problem hiding this comment.
Renamed to assistantToolCallIds.
| import org.springframework.util.Assert; | ||
|
|
||
| /** | ||
| * The decisions a person made on the tool calls of a reply, for a |
There was a problem hiding this comment.
nit: "calls of an AssistantMessage"
| : new AssistantMessage.ToolCall("call_" + UUID.randomUUID(), toolCall.type(), toolCall.name(), | ||
| toolCall.arguments())) | ||
| .toList(); | ||
| return assistantMessage.mutate().toolCalls(toolCalls).build(); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
Destructive use of legacy setter, use parts to preserve OpaquePayload and attributes
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) { |
| return this; | ||
| } | ||
|
|
||
| public Builder requiresConfirmation(boolean requiresConfirmation) { |
There was a problem hiding this comment.
Added on the builder method. The new record component is documented in the class Javadoc with its since version.
| chatClientResponse = paused(chatClientResponse, chatResponse, ex); | ||
| break; | ||
| } | ||
|
|
||
| fullTurnHistory = toolExecutionResult.conversationHistory(); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>
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>
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>
|
@ilayaperumalg , @dimitarproynov . Thanks for the feedback. Just pushed a commit addressing the raised conserns.
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. |
|
Squashed, rebased and merged via 24c782a |
Closes #6916
Related to #3813, #4871
Tools marked
@Tool(requiresConfirmation = true), or selected by aToolCallConfirmationPolicy, 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 withToolCallDecisionsand 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
ToolCallingManagermethod is a default method.Changes
spring-ai-model@Tool.requiresConfirmationandToolMetadata.requiresConfirmation()ToolCallConfirmationPolicy:fromToolMetadata()(default),forTools(...),none(),or(...)DefaultToolCallingManager: checks the policy before any call of the batch runs and throwsToolCallConfirmationRequiredExceptionToolCallingManager.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.spring-ai-client-chatToolCallingAdvisorcatches the exception and returns the paused reply, or a last chunk when streaming, with the awaiting ids in the response metadataspring-ai-autoconfigure-model-toolspring.ai.tools.confirmation.enabledandspring.ai.tools.confirmation.toolsToolCallConfirmationPolicybeans are combined withor, so configuration can only add confirmationsspring.ai.tools.*propertiesspring-ai-google-genaiGoogleGenAiToolCallingManagerdelegates the new methodDocs
api/tools/tool-call-confirmation.adocpage, linked from the tool calling,ToolCallingAdvisorand Tool Search Tool pagesNotes