Skip to content

Commit 71743d9

Browse files
committed
fix(agent): skip unresolved tool names instead of failing agent build
When a configured tool name cannot be resolved by the ToolCallbackResolver (e.g. the model/skill references a non-existent tool), DefaultBuilder.gatherLocalTools() threw IllegalStateException and aborted the whole agent build. Skip the unresolved name with the existing warning instead, consistent with the runtime fallback in AgentToolNode. Closes #4703
1 parent e1b1482 commit 71743d9

2 files changed

Lines changed: 127 additions & 1 deletion

File tree

spring-ai-alibaba-agent-framework/src/main/java/com/alibaba/cloud/ai/graph/agent/DefaultBuilder.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,11 @@ protected List<ToolCallback> gatherLocalTools() {
236236
}
237237
ToolCallback toolCallback = this.resolver.resolve(toolName);
238238
if (toolCallback == null) {
239+
// Tool name could not be resolved (e.g. the model/skill referenced a
240+
// non-existent tool). Skip it instead of failing the whole agent build,
241+
// consistent with the runtime fallback in AgentToolNode.
239242
logger.warn(POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING, toolName);
240-
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
243+
continue;
241244
}
242245
regularTools.add(toolCallback);
243246
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.alibaba.cloud.ai.graph.agent;
17+
18+
import java.util.List;
19+
20+
import org.junit.jupiter.api.DisplayName;
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.ai.tool.ToolCallback;
24+
import org.springframework.ai.tool.definition.ToolDefinition;
25+
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
26+
27+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
28+
import static org.junit.jupiter.api.Assertions.assertEquals;
29+
import static org.junit.jupiter.api.Assertions.assertThrows;
30+
import static org.junit.jupiter.api.Assertions.assertTrue;
31+
import static org.mockito.Mockito.mock;
32+
import static org.mockito.Mockito.when;
33+
34+
/**
35+
* Tests for tool-name resolution in {@link DefaultBuilder#gatherLocalTools()}.
36+
*
37+
* <p>
38+
* When a configured tool name cannot be resolved (e.g. the model/skill referenced a
39+
* non-existent tool), the agent build should not fail; the unresolved name is skipped and
40+
* a warning is logged, consistent with the runtime fallback in {@code AgentToolNode}.
41+
* </p>
42+
*
43+
* @see <a href="https://github.com/alibaba/spring-ai-alibaba/issues/4703">#4703</a>
44+
*/
45+
@DisplayName("DefaultBuilder tool-name resolution Tests")
46+
class DefaultBuilderToolResolutionTest {
47+
48+
/**
49+
* Test helper exposing the protected {@link DefaultBuilder#gatherLocalTools()}.
50+
*/
51+
private static class TestableBuilder extends DefaultBuilder {
52+
53+
List<ToolCallback> callGatherLocalTools() {
54+
return gatherLocalTools();
55+
}
56+
57+
}
58+
59+
private static ToolCallback mockToolNamed(String name) {
60+
ToolDefinition definition = mock(ToolDefinition.class);
61+
when(definition.name()).thenReturn(name);
62+
ToolCallback toolCallback = mock(ToolCallback.class);
63+
when(toolCallback.getToolDefinition()).thenReturn(definition);
64+
return toolCallback;
65+
}
66+
67+
@Test
68+
@DisplayName("Unresolved tool name is skipped instead of failing the build")
69+
void unresolvedToolName_isSkipped_notThrown() {
70+
ToolCallbackResolver resolver = mock(ToolCallbackResolver.class);
71+
when(resolver.resolve("does-not-exist")).thenReturn(null);
72+
73+
TestableBuilder builder = new TestableBuilder();
74+
builder.toolNames("does-not-exist");
75+
builder.resolver(resolver);
76+
77+
List<ToolCallback> tools = assertDoesNotThrow(builder::callGatherLocalTools);
78+
assertTrue(tools.isEmpty(), "Unresolved tool name should be skipped");
79+
}
80+
81+
@Test
82+
@DisplayName("Resolvable tool name is still added (regression)")
83+
void resolvableToolName_isAdded() {
84+
ToolCallback resolved = mockToolNamed("real-tool");
85+
ToolCallbackResolver resolver = mock(ToolCallbackResolver.class);
86+
when(resolver.resolve("real-tool")).thenReturn(resolved);
87+
88+
TestableBuilder builder = new TestableBuilder();
89+
builder.toolNames("real-tool");
90+
builder.resolver(resolver);
91+
92+
List<ToolCallback> tools = builder.callGatherLocalTools();
93+
assertEquals(1, tools.size());
94+
assertEquals(resolved, tools.get(0));
95+
}
96+
97+
@Test
98+
@DisplayName("A resolvable tool is kept even when another name is unresolved")
99+
void mixedToolNames_keepResolvedSkipUnresolved() {
100+
ToolCallback resolved = mockToolNamed("real-tool");
101+
ToolCallbackResolver resolver = mock(ToolCallbackResolver.class);
102+
when(resolver.resolve("real-tool")).thenReturn(resolved);
103+
when(resolver.resolve("ghost-tool")).thenReturn(null);
104+
105+
TestableBuilder builder = new TestableBuilder();
106+
builder.toolNames("real-tool", "ghost-tool");
107+
builder.resolver(resolver);
108+
109+
List<ToolCallback> tools = assertDoesNotThrow(builder::callGatherLocalTools);
110+
assertEquals(1, tools.size());
111+
assertEquals(resolved, tools.get(0));
112+
}
113+
114+
@Test
115+
@DisplayName("Null resolver with tool names still fails fast (unchanged behavior)")
116+
void nullResolver_withToolNames_stillThrows() {
117+
TestableBuilder builder = new TestableBuilder();
118+
builder.toolNames("any-tool");
119+
120+
assertThrows(IllegalStateException.class, builder::callGatherLocalTools);
121+
}
122+
123+
}

0 commit comments

Comments
 (0)