Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ public abstract class Builder {
protected CompileConfig compileConfig;

protected List<Hook> hooks = new ArrayList<>();

protected boolean disableDefaultUnknownToolGuard = false;

protected boolean disableDefaultToolExecutionFailureGuard = false;

protected List<Interceptor> interceptors = new ArrayList<>();
protected List<ModelInterceptor> modelInterceptors = new ArrayList<>();
protected List<ToolInterceptor> toolInterceptors = new ArrayList<>();
Expand Down Expand Up @@ -291,6 +296,40 @@ public Builder hooks(Hook... hooks) {
return this;
}

/**
* Disables the default {@code UnknownToolGuardHook} that is automatically registered
* when tools are present. Use this when you have a custom unknown-tool handling strategy
* or want to rely solely on {@code maxIterations} for loop control.
* @return this builder for chaining
*/
public Builder disableDefaultUnknownToolGuard() {
this.disableDefaultUnknownToolGuard = true;
return this;
}

/**
* Disables the default {@code ToolExecutionFailureGuardHook} that is automatically
* registered when tools are present. Use this when you have a custom failure handling
* strategy or want to rely solely on {@code maxIterations} for loop control.
* @return this builder for chaining
*/
public Builder disableDefaultToolExecutionFailureGuard() {
this.disableDefaultToolExecutionFailureGuard = true;
return this;
}

/**
* Disables all default guard hooks ({@code UnknownToolGuardHook} and
* {@code ToolExecutionFailureGuardHook}). Equivalent to calling both
* {@link #disableDefaultUnknownToolGuard()} and {@link #disableDefaultToolExecutionFailureGuard()}.
* @return this builder for chaining
*/
public Builder disableDefaultGuards() {
this.disableDefaultUnknownToolGuard = true;
this.disableDefaultToolExecutionFailureGuard = true;
return this;
}

public Builder interceptors(List<? extends Interceptor> interceptors) {
Assert.notNull(interceptors, "interceptors cannot be null");
Assert.noNullElements(interceptors, "interceptors cannot contain null elements");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
import com.alibaba.cloud.ai.graph.agent.hook.ModelHook;
import com.alibaba.cloud.ai.graph.agent.hook.ToolInjection;
import com.alibaba.cloud.ai.graph.agent.hook.hip.HumanInTheLoopHook;
import com.alibaba.cloud.ai.graph.agent.hook.toolexecutionfailure.ToolExecutionFailureGuardHook;
import com.alibaba.cloud.ai.graph.agent.hook.unknowntool.UnknownToolGuardHook;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelInterceptor;
import com.alibaba.cloud.ai.graph.agent.interceptor.ToolInterceptor;
import com.alibaba.cloud.ai.graph.agent.node.AgentLlmNode;
Expand Down Expand Up @@ -121,8 +123,9 @@ public ReactAgent(AgentLlmNode llmNode, AgentToolNode toolNode, CompileConfig co
this.instruction = builder.instruction;
this.llmNode = llmNode;
this.toolNode = toolNode;
this.hasTools = toolNode.getToolCallbacks() != null && !toolNode.getToolCallbacks().isEmpty();
this.compileConfig = compileConfig;
this.hooks = builder.hooks;
this.hooks = buildEffectiveHooks(builder.hooks, builder);
this.modelInterceptors = builder.modelInterceptors;
this.toolInterceptors = builder.toolInterceptors;
this.includeContents = builder.includeContents;
Expand Down Expand Up @@ -150,8 +153,6 @@ public ReactAgent(AgentLlmNode llmNode, AgentToolNode toolNode, CompileConfig co
this.toolNode.setToolInterceptors(mergedToolInterceptors);
}

// Set tools flag if tool interceptors are present.
hasTools = toolNode.getToolCallbacks() != null && !toolNode.getToolCallbacks().isEmpty();
}

public static Builder builder() {
Expand Down Expand Up @@ -302,15 +303,7 @@ public Node asNode(boolean includeContents, boolean returnReasoningContents) {

@Override
protected StateGraph initGraph() throws GraphStateException {

if (hooks == null) {
hooks = new ArrayList<>();
}

// Always inject default InstructionAgentHook so instruction is handled in beforeAgent
List<Hook> effectiveHooks = new ArrayList<>();
effectiveHooks.add(InstructionAgentHook.create());
effectiveHooks.addAll(hooks);
List<Hook> effectiveHooks = hooks == null ? new ArrayList<>() : new ArrayList<>(hooks);

// Validate hook uniqueness
Set<String> hookNames = new HashSet<>();
Expand Down Expand Up @@ -398,6 +391,26 @@ protected StateGraph initGraph() throws GraphStateException {
return graph;
}

private List<Hook> buildEffectiveHooks(List<Hook> configuredHooks, Builder builder) {
List<Hook> effectiveHooks = new ArrayList<>();
effectiveHooks.add(InstructionAgentHook.create());
// UnknownToolGuardHook is registered regardless of whether tools are configured,
// because models can hallucinate tool calls even when no tools are available.
// e.g, if the SKILL.md description requires calling a tool, the model may have a certain probability of calling the wrong tool.
if (!builder.disableDefaultUnknownToolGuard
&& (configuredHooks == null || configuredHooks.stream().noneMatch(UnknownToolGuardHook.class::isInstance))) {
effectiveHooks.add(UnknownToolGuardHook.create());
}
if (hasTools && !builder.disableDefaultToolExecutionFailureGuard
&& (configuredHooks == null || configuredHooks.stream().noneMatch(ToolExecutionFailureGuardHook.class::isInstance))) {
effectiveHooks.add(ToolExecutionFailureGuardHook.create());
}
if (configuredHooks != null && !configuredHooks.isEmpty()) {
effectiveHooks.addAll(configuredHooks);
}
return effectiveHooks;
}

/**
* Setup and inject tools for hooks that implement ToolInjection interface.
* Only the tool matching the hook's required tool name or type will be injected.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.cloud.ai.graph.agent.hook;

import com.alibaba.cloud.ai.graph.agent.interceptor.ModelCallHandler;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelInterceptor;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelRequest;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelResponse;
import com.alibaba.cloud.ai.graph.serializer.AgentInstructionMessage;

import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.model.tool.ToolCallingChatOptions;

import java.util.List;
import java.util.Map;

/**
* Shared template for guard interceptors that enforce a synthetic final-answer turn by disabling
* all tool exposure for the model.
*
* <p>
* Guard hooks such as {@link AbstractToolCallGuardHook} inject an {@link AgentInstructionMessage}
* when they decide the model should stop calling tools and answer directly. This interceptor runs
* on the subsequent model call, detects that synthetic instruction via metadata, and strips tool
* callbacks, tool descriptions, and internal tool execution settings from the request.
* </p>
*
* <p>
* In other words, the hook decides <em>when</em> to enter final-answer mode, while this interceptor
* enforces <em>how</em> that next model turn is executed.
* </p>
*/
public abstract class AbstractFinalAnswerInterceptor extends ModelInterceptor {

/**
* Intercept the current model request.
* <p>
* If the last message is not the synthetic final-answer instruction, the request is passed through
* unchanged. If the final-answer instruction is present, the request is cloned with tool exposure
* removed so the model can only produce a direct answer for that turn.
* </p>
* @param request the current model request
* @param handler the downstream model call handler
* @return the downstream model response, using either the original or the tool-stripped request
*/
@Override
public final ModelResponse interceptModel(ModelRequest request, ModelCallHandler handler) {
if (!shouldDisableTools(request)) {
return handler.call(request);
}
return handler.call(disableToolExposure(request));
}

/**
* Determine whether the current model turn is the synthetic final-answer turn created by a guard hook.
* <p>
* The default implementation only activates when the last message is an {@link AgentInstructionMessage}
* whose metadata contains {@link #finalAnswerInstructionMetadataKey()} set to {@code true}.
* </p>
* @param request the current model request
* @return {@code true} if tools should be disabled for this request
*/
protected boolean shouldDisableTools(ModelRequest request) {
List<Message> messages = request.getMessages();
if (messages == null || messages.isEmpty()) {
return false;
}

Message lastMessage = messages.get(messages.size() - 1);
if (!(lastMessage instanceof AgentInstructionMessage instructionMessage)) {
return false;
}

return Boolean.TRUE.equals(instructionMessage.getMetadata().get(finalAnswerInstructionMetadataKey()));
}

/**
* Metadata key that marks the synthetic final-answer instruction injected by a guard
* hook.
* <p>
* Each guard type uses its own key so the matching interceptor only reacts to instructions
* created by that specific guard.
* </p>
* @return the metadata key used to detect final-answer mode
*/
protected abstract String finalAnswerInstructionMetadataKey();

private ModelRequest disableToolExposure(ModelRequest request) {
ToolCallingChatOptions options = request.getOptions();
if (options != null) {
options = options.copy();
options.setToolCallbacks(List.of());
options.setInternalToolExecutionEnabled(false);
}

return ModelRequest.builder(request)
.options(options)
.tools(List.of())
.dynamicToolCallbacks(List.of())
.toolDescriptions(Map.of())
.build();
}

}


Loading
Loading