Skip to content

Commit f6ac3b5

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add tools and toolset to use SkillSource in ADK agents
This change re-introduces the tools and toolset to enable ADK agents to use skills loaded via `SkillSource`. Key changes: - ListSkillsTool: Lists available skills. - LoadSkillTool: Loads skill instructions. Also add a code change to support serialize the custom autovalue class - LoadSkillResourceTool: Loads skill resources. - SkillToolset: Groups the above tools. - Integrates SkillSource into LlmAgent and BaseLlmFlow. - Tests for all new tools. The fix for the original TAP breakage (NPE in mocked tests like DataExpressAgentTest): - Reverted the LlmRequestProcessor interface workaround proposed in CL 920071248. - Restored the original design from CL 919759154 where `processLlmRequest` is directly on `BaseToolset`. - Made `BaseLlmFlow.getRequestProcessorFromTools` null-safe. This safely handles null returns from `processLlmRequest`, which commonly happens in mocked environments (e.g., Mockito mocks of toolsets/tools created in test suites without explicit stubbing of processLlmRequest). PiperOrigin-RevId: 921615212
1 parent d3e7f31 commit f6ac3b5

18 files changed

Lines changed: 1697 additions & 37 deletions

core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
import com.google.adk.models.LlmRequest;
3939
import com.google.adk.models.LlmResponse;
4040
import com.google.adk.telemetry.Tracing;
41+
import com.google.adk.tools.BaseTool;
42+
import com.google.adk.tools.BaseToolset;
4143
import com.google.adk.tools.ToolContext;
44+
import com.google.common.annotations.VisibleForTesting;
4245
import com.google.common.collect.ImmutableList;
4346
import com.google.common.collect.Iterables;
4447
import com.google.genai.types.FunctionResponse;
@@ -58,6 +61,7 @@
5861
import java.util.Optional;
5962
import java.util.Set;
6063
import java.util.concurrent.atomic.AtomicReference;
64+
import java.util.function.BiFunction;
6165
import org.slf4j.Logger;
6266
import org.slf4j.LoggerFactory;
6367

@@ -96,20 +100,8 @@ private Flowable<Event> preprocess(
96100
Context currentContext = Context.current();
97101
LlmAgent agent = (LlmAgent) context.agent();
98102

99-
RequestProcessor toolsProcessor =
100-
(ctx, req) -> {
101-
LlmRequest.Builder builder = req.toBuilder();
102-
return agent
103-
.canonicalTools(new ReadonlyContext(ctx))
104-
.concatMapCompletable(
105-
tool -> tool.processLlmRequest(builder, ToolContext.builder(ctx).build()))
106-
.andThen(
107-
Single.fromCallable(
108-
() -> RequestProcessingResult.create(builder.build(), ImmutableList.of())));
109-
};
110-
111103
Iterable<RequestProcessor> allProcessors =
112-
Iterables.concat(requestProcessors, ImmutableList.of(toolsProcessor));
104+
Iterables.concat(requestProcessors, ImmutableList.of(getRequestProcessorFromTools(agent)));
113105

114106
return Flowable.fromIterable(allProcessors)
115107
.concatMap(
@@ -121,6 +113,58 @@ private Flowable<Event> preprocess(
121113
result -> result.events() != null ? result.events() : ImmutableList.of()));
122114
}
123115

116+
/**
117+
* Constructs a {@link RequestProcessor} that sequentially applies the {@code processLlmRequest}
118+
* methods of all tools and toolsets associated with this agent to the incoming {@link
119+
* LlmRequest}.
120+
*
121+
* @return A {@link RequestProcessor} that applies tool-specific modifications to LLM requests.
122+
*/
123+
@VisibleForTesting
124+
RequestProcessor getRequestProcessorFromTools(LlmAgent agent) {
125+
return (context, request) -> {
126+
ReadonlyContext readonlyContext = new ReadonlyContext(context);
127+
List<BiFunction<LlmRequest.Builder, ToolContext, Completable>> processors = new ArrayList<>();
128+
129+
for (Object toolOrToolset : agent.toolsUnion()) {
130+
if (toolOrToolset instanceof BaseTool baseTool) {
131+
processors.add(
132+
(builder, ctx) -> {
133+
Completable c = baseTool.processLlmRequest(builder, ctx);
134+
return c == null ? Completable.complete() : c;
135+
});
136+
} else if (toolOrToolset instanceof BaseToolset baseToolset) {
137+
// First apply the toolset's own request processor, then unwrap all tools from the toolset
138+
// and apply each individual tool's request processor sequentially.
139+
processors.add(
140+
(builder, ctx) -> {
141+
Completable c = baseToolset.processLlmRequest(builder, ctx);
142+
Completable toolsetProcessor = c == null ? Completable.complete() : c;
143+
return toolsetProcessor
144+
.andThen(baseToolset.getTools(readonlyContext))
145+
.concatMapCompletable(
146+
b -> {
147+
Completable tc = b.processLlmRequest(builder, ctx);
148+
return tc == null ? Completable.complete() : tc;
149+
});
150+
});
151+
} else {
152+
throw new IllegalArgumentException(
153+
"Object in tools list is not of a supported type: "
154+
+ toolOrToolset.getClass().getName());
155+
}
156+
}
157+
158+
LlmRequest.Builder builder = request.toBuilder();
159+
ToolContext toolContext = ToolContext.builder(context).build();
160+
return Flowable.fromIterable(processors)
161+
.concatMapCompletable(f -> f.apply(builder, toolContext))
162+
.andThen(
163+
Single.fromCallable(
164+
() -> RequestProcessingResult.create(builder.build(), ImmutableList.of())));
165+
};
166+
}
167+
124168
/**
125169
* Post-processes the LLM response after receiving it from the LLM. Executes all registered {@link
126170
* ResponseProcessor} instances. Emits events for the model response and any subsequent function

core/src/main/java/com/google/adk/skills/AbstractSkillSource.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package com.google.adk.skills;
1818

19+
import static com.google.adk.skills.SkillSourceException.SKILL_FORMAT_ERROR;
20+
import static com.google.adk.skills.SkillSourceException.SKILL_LOAD_ERROR;
1921
import static java.nio.channels.Channels.newReader;
2022
import static java.nio.charset.StandardCharsets.UTF_8;
2123

@@ -82,12 +84,14 @@ private Frontmatter loadFrontmatter(String skillName, PathT skillMdPath)
8284
Frontmatter frontmatter = yamlMapper.readValue(yaml, Frontmatter.class);
8385
if (!frontmatter.name().equals(skillName)) {
8486
throw new SkillSourceException(
85-
"Skill name '%s' does not match directory name '%s'."
86-
.formatted(frontmatter.name(), skillName));
87+
"Skill name in the frontmatter '%s' does not match skill name '%s'."
88+
.formatted(frontmatter.name(), skillName),
89+
SKILL_LOAD_ERROR);
8790
}
8891
return frontmatter;
8992
} catch (IOException e) {
90-
throw new SkillSourceException("Cannot load frontmatter for skill '" + skillName + "'", e);
93+
throw new SkillSourceException(
94+
"Cannot load frontmatter for skill '" + skillName + "'", SKILL_LOAD_ERROR, e);
9195
}
9296
}
9397

@@ -100,7 +104,9 @@ public Single<String> loadInstructions(String skillName) {
100104
return readInstructions(reader);
101105
} catch (IOException e) {
102106
throw new SkillSourceException(
103-
"Failed to load instruction for skill '" + skillName + "'", e);
107+
"Failed to load instruction for skill '" + skillName + "'",
108+
SKILL_LOAD_ERROR,
109+
e);
104110
}
105111
});
106112
}
@@ -140,7 +146,8 @@ private String readFrontmatterYaml(BufferedReader reader)
140146
throws IOException, SkillSourceException {
141147
String line = reader.readLine();
142148
if (line == null || !line.trim().equals(THREE_DASHES)) {
143-
throw new SkillSourceException("Skill file must start with " + THREE_DASHES);
149+
throw new SkillSourceException(
150+
"Skill file must start with " + THREE_DASHES, SKILL_FORMAT_ERROR);
144151
}
145152

146153
StringBuilder sb = new StringBuilder();
@@ -151,14 +158,15 @@ private String readFrontmatterYaml(BufferedReader reader)
151158
sb.append(line).append("\n");
152159
}
153160
throw new SkillSourceException(
154-
"Skill file frontmatter not properly closed with " + THREE_DASHES);
161+
"Skill file frontmatter not properly closed with " + THREE_DASHES, SKILL_FORMAT_ERROR);
155162
}
156163

157164
private String readInstructions(BufferedReader reader) throws IOException, SkillSourceException {
158165
// Skip the frontmatter block
159166
String line = reader.readLine();
160167
if (line == null || !line.trim().equals(THREE_DASHES)) {
161-
throw new SkillSourceException("Skill file must start with " + THREE_DASHES);
168+
throw new SkillSourceException(
169+
"Skill file must start with " + THREE_DASHES, SKILL_FORMAT_ERROR);
162170
}
163171
boolean dashClosed = false;
164172
while ((line = reader.readLine()) != null) {
@@ -169,7 +177,7 @@ private String readInstructions(BufferedReader reader) throws IOException, Skill
169177
}
170178
if (!dashClosed) {
171179
throw new SkillSourceException(
172-
"Skill file frontmatter not properly closed with " + THREE_DASHES);
180+
"Skill file frontmatter not properly closed with " + THREE_DASHES, SKILL_FORMAT_ERROR);
173181
}
174182
// Read the instructions till the end of the file
175183
StringBuilder sb = new StringBuilder();

core/src/main/java/com/google/adk/skills/InMemorySkillSource.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package com.google.adk.skills;
1818

19+
import static com.google.adk.skills.SkillSourceException.RESOURCE_NOT_FOUND;
20+
import static com.google.adk.skills.SkillSourceException.SKILL_NOT_FOUND;
1921
import static com.google.common.base.Preconditions.checkState;
2022
import static com.google.common.collect.ImmutableList.toImmutableList;
2123
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -56,7 +58,8 @@ public Single<ImmutableMap<String, Frontmatter>> listFrontmatters() {
5658
public Single<ImmutableList<String>> listResources(String skillName, String resourceDirectory) {
5759
SkillData data = skills.get(skillName);
5860
if (data == null) {
59-
return Single.error(new SkillSourceException("Skill not found: " + skillName));
61+
return Single.error(
62+
new SkillSourceException("Skill not found: " + skillName, SKILL_NOT_FOUND));
6063
}
6164
String prefix =
6265
resourceDirectory.isEmpty()
@@ -67,7 +70,8 @@ public Single<ImmutableList<String>> listResources(String skillName, String reso
6770
&& data.resources().keySet().stream().noneMatch(path -> path.startsWith(prefix))) {
6871
return Single.error(
6972
new SkillSourceException(
70-
"Resource directory not found: " + resourceDirectory + " for skill: " + skillName));
73+
"Resource directory not found: " + resourceDirectory + " for skill: " + skillName,
74+
RESOURCE_NOT_FOUND));
7175
}
7276

7377
return Single.just(
@@ -92,13 +96,16 @@ public Single<ByteSource> loadResource(String skillName, String resourcePath) {
9296
.map(SkillData::resources)
9397
.mapOptional(m -> Optional.ofNullable(m.get(resourcePath)))
9498
.switchIfEmpty(
95-
Single.error(new SkillSourceException("Resource not found: " + resourcePath)));
99+
Single.error(
100+
new SkillSourceException(
101+
"Resource not found: " + resourcePath, RESOURCE_NOT_FOUND)));
96102
}
97103

98104
private Single<SkillData> getSkillData(String skillName) {
99105
SkillData data = skills.get(skillName);
100106
if (data == null) {
101-
return Single.error(new SkillSourceException("Skill not found: " + skillName));
107+
return Single.error(
108+
new SkillSourceException("Skill not found: " + skillName, SKILL_NOT_FOUND));
102109
}
103110
return Single.just(data);
104111
}

core/src/main/java/com/google/adk/skills/LocalSkillSource.java

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
package com.google.adk.skills;
1818

19+
import static com.google.adk.skills.SkillSourceException.RESOURCE_LOAD_ERROR;
20+
import static com.google.adk.skills.SkillSourceException.RESOURCE_NOT_FOUND;
21+
import static com.google.adk.skills.SkillSourceException.SKILL_LOAD_ERROR;
22+
import static com.google.adk.skills.SkillSourceException.SKILL_NOT_FOUND;
1923
import static com.google.common.collect.ImmutableList.toImmutableList;
2024
import static java.nio.file.Files.isDirectory;
2125

@@ -43,14 +47,16 @@ public LocalSkillSource(Path skillsBasePath) {
4347
public Single<ImmutableList<String>> listResources(String skillName, String resourceDirectory) {
4448
Path skillDir = skillsBasePath.resolve(skillName);
4549
if (!isDirectory(skillDir)) {
46-
return Single.error(new SkillSourceException("Skill not found: " + skillName));
50+
return Single.error(
51+
new SkillSourceException("Skill not found: " + skillName, SKILL_NOT_FOUND));
4752
}
4853
Path resourceDir = skillDir.resolve(resourceDirectory);
4954
if (!isDirectory(resourceDir)) {
5055
return Single.error(
5156
new SkillSourceException(
5257
"Resource directory '%s' not found for skill '%s'"
53-
.formatted(resourceDirectory, skillName)));
58+
.formatted(resourceDirectory, skillName),
59+
RESOURCE_NOT_FOUND));
5460
}
5561

5662
return Single.fromCallable(
@@ -67,7 +73,9 @@ public Single<ImmutableList<String>> listResources(String skillName, String reso
6773
t ->
6874
Single.error(
6975
new SkillSourceException(
70-
"Failed to traverse resource directory: " + resourceDirectory, t)));
76+
"Failed to traverse resource directory: " + resourceDirectory,
77+
RESOURCE_LOAD_ERROR,
78+
t)));
7179
}
7280

7381
@Override
@@ -78,7 +86,9 @@ protected Flowable<SkillMdPath> listSkills() {
7886
t ->
7987
Flowable.error(
8088
new SkillSourceException(
81-
"Failed to list skills in directory: " + skillsBasePath, t)))
89+
"Failed to list skills in directory: " + skillsBasePath,
90+
SKILL_LOAD_ERROR,
91+
t)))
8292
.filter(Files::isDirectory)
8393
.mapOptional(this::findSkillMd)
8494
.map(skillMd -> new SkillMdPath(skillMd.getParent().getFileName().toString(), skillMd));
@@ -88,7 +98,8 @@ protected Flowable<SkillMdPath> listSkills() {
8898
protected Single<Path> findResourcePath(String skillName, String resourcePath) {
8999
Path file = skillsBasePath.resolve(skillName).resolve(resourcePath);
90100
if (!Files.exists(file)) {
91-
return Single.error(new SkillSourceException("Resource not found: " + file));
101+
return Single.error(
102+
new SkillSourceException("Resource not found: " + file, RESOURCE_NOT_FOUND));
92103
}
93104
return Single.just(file);
94105
}
@@ -97,11 +108,13 @@ protected Single<Path> findResourcePath(String skillName, String resourcePath) {
97108
protected Single<Path> findSkillMdPath(String skillName) {
98109
Path skillDir = skillsBasePath.resolve(skillName);
99110
if (!isDirectory(skillDir)) {
100-
return Single.error(new SkillSourceException("Skill directory not found: " + skillName));
111+
return Single.error(
112+
new SkillSourceException("Skill directory not found: " + skillName, SKILL_NOT_FOUND));
101113
}
102114
return Maybe.fromOptional(findSkillMd(skillDir))
103115
.switchIfEmpty(
104-
Single.error(new SkillSourceException("SKILL.md not found in " + skillName)));
116+
Single.error(
117+
new SkillSourceException("SKILL.md not found in " + skillName, SKILL_NOT_FOUND)));
105118
}
106119

107120
@Override

core/src/main/java/com/google/adk/skills/SkillSourceException.java

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,43 @@
2222
*/
2323
public final class SkillSourceException extends Exception {
2424

25-
public SkillSourceException(String message) {
25+
public static final String SKILL_LOAD_ERROR = "SKILL_LOAD_ERROR";
26+
public static final String SKILL_NOT_FOUND = "SKILL_NOT_FOUND";
27+
public static final String SKILL_FORMAT_ERROR = "SKILL_FORMAT_ERROR";
28+
public static final String RESOURCE_LOAD_ERROR = "RESOURCE_LOAD_ERROR";
29+
public static final String RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND";
30+
31+
private final String errorCode;
32+
33+
/**
34+
* Constructs a new exception with the specified detail message and error code.
35+
*
36+
* @param message The detail message.
37+
* @param errorCode The specific error code categorizing the failure.
38+
*/
39+
public SkillSourceException(String message, String errorCode) {
2640
super(message);
41+
this.errorCode = errorCode;
2742
}
2843

29-
public SkillSourceException(String message, Throwable cause) {
44+
/**
45+
* Constructs a new exception with the specified detail message, error code, and cause.
46+
*
47+
* @param message The detail message.
48+
* @param errorCode The specific error code categorizing the failure.
49+
* @param cause The cause.
50+
*/
51+
public SkillSourceException(String message, String errorCode, Throwable cause) {
3052
super(message, cause);
53+
this.errorCode = errorCode;
54+
}
55+
56+
/**
57+
* Returns the error code categorizing the failure.
58+
*
59+
* @return The error code string.
60+
*/
61+
public String getErrorCode() {
62+
return errorCode;
3163
}
3264
}

core/src/main/java/com/google/adk/tools/BaseToolset.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,26 @@
1717
package com.google.adk.tools;
1818

1919
import com.google.adk.agents.ReadonlyContext;
20+
import com.google.adk.models.LlmRequest;
21+
import io.reactivex.rxjava3.core.Completable;
2022
import io.reactivex.rxjava3.core.Flowable;
2123
import java.util.List;
2224
import org.jspecify.annotations.Nullable;
2325

2426
/** Base interface for toolsets. */
2527
public interface BaseToolset extends AutoCloseable {
2628

29+
/** Processes the outgoing {@link LlmRequest.Builder}. */
30+
default Completable processLlmRequest(
31+
LlmRequest.Builder llmRequestBuilder, ToolContext toolContext) {
32+
return Completable.complete();
33+
}
34+
2735
/**
2836
* Return all tools in the toolset based on the provided context.
2937
*
3038
* @param readonlyContext Context used to filter tools available to the agent.
31-
* @return A Single emitting a list of tools available under the specified context.
39+
* @return A Flowable emitting tools available under the specified context.
3240
*/
3341
Flowable<BaseTool> getTools(ReadonlyContext readonlyContext);
3442

0 commit comments

Comments
 (0)