Skip to content
Closed
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
44 changes: 44 additions & 0 deletions core/src/main/java/com/google/adk/agents/LlmAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,16 @@
import com.google.adk.events.Event;
import com.google.adk.flows.llmflows.AutoFlow;
import com.google.adk.flows.llmflows.BaseLlmFlow;
import com.google.adk.flows.llmflows.RequestProcessor;
import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult;
import com.google.adk.flows.llmflows.SingleFlow;
import com.google.adk.models.BaseLlm;
import com.google.adk.models.LlmRegistry;
import com.google.adk.models.LlmRequest;
import com.google.adk.models.Model;
import com.google.adk.tools.BaseTool;
import com.google.adk.tools.BaseToolset;
import com.google.adk.tools.ToolContext;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
Expand All @@ -70,6 +74,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Executor;
import java.util.function.BiFunction;
import java.util.function.Function;
import javax.annotation.Nullable;
import org.slf4j.Logger;
Expand Down Expand Up @@ -770,6 +775,45 @@ public Flowable<BaseTool> canonicalTools(@Nullable ReadonlyContext context) {
return Flowable.concat(toolFlowables);
}

/**
* Constructs a {@link RequestProcessor} that sequentially applies the {@code processLlmRequest}
* methods of all tools and toolsets associated with this agent to the incoming {@link
* LlmRequest}.
*
* @return A {@link RequestProcessor} that applies tool-specific modifications to LLM requests.
*/
public RequestProcessor getRequestProcessorFromTools() {
return (context, request) -> {
ReadonlyContext readonlyContext = new ReadonlyContext(context);
List<BiFunction<LlmRequest.Builder, ToolContext, Completable>> processors = new ArrayList<>();

for (Object toolOrToolset : toolsUnion()) {
if (toolOrToolset instanceof BaseTool baseTool) {
processors.add(baseTool::processLlmRequest);
} else if (toolOrToolset instanceof BaseToolset baseToolset) {
processors.add(
(builder, ctx) ->
baseToolset
.processLlmRequest(builder, ctx)
.andThen(baseToolset.getTools(readonlyContext))
.concatMapCompletable(b -> b.processLlmRequest(builder, ctx)));
} else {
throw new IllegalArgumentException(
"Object in tools list is not of a supported type: "
+ toolOrToolset.getClass().getName());
}
}

LlmRequest.Builder builder = request.toBuilder();
ToolContext toolContext = ToolContext.builder(context).build();
return Flowable.fromIterable(processors)
.concatMapCompletable(f -> f.apply(builder, toolContext))
.andThen(
Single.fromCallable(
() -> RequestProcessingResult.create(builder.build(), ImmutableList.of())));
};
}

public Instruction instruction() {
return instruction;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@
import com.google.adk.agents.InvocationContext;
import com.google.adk.agents.LiveRequest;
import com.google.adk.agents.LlmAgent;
import com.google.adk.agents.ReadonlyContext;
import com.google.adk.agents.RunConfig.StreamingMode;
import com.google.adk.events.Event;
import com.google.adk.flows.BaseFlow;
import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult;
import com.google.adk.flows.llmflows.ResponseProcessor.ResponseProcessingResult;
import com.google.adk.models.BaseLlm;
import com.google.adk.models.BaseLlmConnection;
Expand All @@ -38,7 +36,6 @@
import com.google.adk.models.LlmRequest;
import com.google.adk.models.LlmResponse;
import com.google.adk.telemetry.Tracing;
import com.google.adk.tools.ToolContext;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.genai.types.FunctionResponse;
Expand Down Expand Up @@ -96,20 +93,8 @@ private Flowable<Event> preprocess(
Context currentContext = Context.current();
LlmAgent agent = (LlmAgent) context.agent();

RequestProcessor toolsProcessor =
(ctx, req) -> {
LlmRequest.Builder builder = req.toBuilder();
return agent
.canonicalTools(new ReadonlyContext(ctx))
.concatMapCompletable(
tool -> tool.processLlmRequest(builder, ToolContext.builder(ctx).build()))
.andThen(
Single.fromCallable(
() -> RequestProcessingResult.create(builder.build(), ImmutableList.of())));
};

Iterable<RequestProcessor> allProcessors =
Iterables.concat(requestProcessors, ImmutableList.of(toolsProcessor));
Iterables.concat(requestProcessors, ImmutableList.of(agent.getRequestProcessorFromTools()));

return Flowable.fromIterable(allProcessors)
.concatMap(
Expand Down
60 changes: 60 additions & 0 deletions core/src/main/java/com/google/adk/skills/AbstractSkillLoader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.google.adk.skills;

import static org.apache.arrow.util.Preconditions.checkArgument;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Base class containing common parsing logic for SkillLoaders. */
public abstract class AbstractSkillLoader implements SkillLoader {
private static final Logger logger = LoggerFactory.getLogger(AbstractSkillLoader.class);
private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());

/** Record to hold the parsed SKILL.md content. */
protected record ParsedSkillMd(Frontmatter frontmatter, String body) {}

/**
* Retrieves all available parsed SKILL.md files.
*
* @return A map where keys are skill IDs (directory names) and values are the parsed SKILL.md
* contents, or empty if none are found.
*/
protected abstract ImmutableMap<String, ParsedSkillMd> loadAllParsedSkills();

@Override
public final ImmutableMap<String, Frontmatter> listSkills() {
ImmutableMap.Builder<String, Frontmatter> builder = ImmutableMap.builder();
loadAllParsedSkills()
.forEach(
(skillId, parsedSkill) -> {
if (skillId.equals(parsedSkill.frontmatter().name())) {
builder.put(skillId, parsedSkill.frontmatter());
} else {
logger.warn("Skipping invalid skill: Name does not match directory. {}", skillId);
}
});

return builder.buildOrThrow();
}

protected final ParsedSkillMd parseSkillMdContent(String content) {
checkArgument(content.startsWith("---"), "SKILL.md must start with YAML frontmatter (---)");

String[] parts = content.split("---", 3);
checkArgument(parts.length >= 3, "SKILL.md frontmatter not properly closed with ---");

String frontmatterStr = parts[1];
String body = parts[2].trim();

try {
Frontmatter fm = yamlMapper.readValue(frontmatterStr, Frontmatter.class);
return new ParsedSkillMd(fm, body);
} catch (IOException e) {
throw new IllegalArgumentException("Invalid YAML in frontmatter", e);
}
}
}
143 changes: 143 additions & 0 deletions core/src/main/java/com/google/adk/skills/Frontmatter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright 2026 Google LLC
*
* 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
*
* http://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.google.adk.skills;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.google.adk.JsonBaseModel;
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableMap;
import com.google.common.escape.Escaper;
import com.google.common.html.HtmlEscapers;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;

/** L1 skill content: metadata parsed from SKILL.md for skill discovery. */
@AutoValue
@JsonDeserialize(builder = Frontmatter.Builder.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public abstract class Frontmatter extends JsonBaseModel {

private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");

/** Skill name in kebab-case. */
@JsonProperty("name")
public abstract String name();

/** What the skill does and when the model should use it. */
@JsonProperty("description")
public abstract String description();

/** License for the skill. */
@JsonProperty("license")
public abstract Optional<String> license();

/** Compatibility information for the skill. */
@JsonProperty("compatibility")
public abstract Optional<String> compatibility();

/** A space-delimited list of tools that are pre-approved to run. */
@JsonProperty("allowedTools")
public abstract Optional<String> allowedTools();

/** Key-value pairs for client-specific properties. */
@JsonProperty("metadata")
public abstract ImmutableMap<String, Object> metadata();

public String toXml() {
Escaper escaper = HtmlEscapers.htmlEscaper();
return String.format(
"""
<skill>
<name>
%s
</name>
<description>
%s
</description>
</skill>
""",
escaper.escape(name()), escaper.escape(description()));
}

public static Builder builder() {
return new AutoValue_Frontmatter.Builder().metadata(ImmutableMap.of());
}

@AutoValue.Builder
public abstract static class Builder {

@JsonCreator
private static Builder create() {
return builder();
}

@CanIgnoreReturnValue
@JsonProperty("name")
public abstract Builder name(String name);

@CanIgnoreReturnValue
@JsonProperty("description")
public abstract Builder description(String description);

@CanIgnoreReturnValue
@JsonProperty("license")
public abstract Builder license(String license);

@CanIgnoreReturnValue
@JsonProperty("compatibility")
public abstract Builder compatibility(String compatibility);

@CanIgnoreReturnValue
@JsonProperty("allowed-tools")
@JsonAlias({"allowed_tools"})
public abstract Builder allowedTools(String allowedTools);

@CanIgnoreReturnValue
@JsonProperty("metadata")
public abstract Builder metadata(Map<String, Object> metadata);

abstract Frontmatter autoBuild();

public Frontmatter build() {
Frontmatter fm = autoBuild();
if (fm.name().length() > 64) {
throw new IllegalArgumentException("name must be at most 64 characters");
}
if (!NAME_PATTERN.matcher(fm.name()).matches()) {
throw new IllegalArgumentException(
"name must be lowercase kebab-case (a-z, 0-9, hyphens), with no leading, trailing, or"
+ " consecutive hyphens");
}
if (fm.description().isEmpty()) {
throw new IllegalArgumentException("description must not be empty");
}
if (fm.description().length() > 1024) {
throw new IllegalArgumentException("description must be at most 1024 characters");
}
if (fm.compatibility().isPresent() && fm.compatibility().get().length() > 500) {
throw new IllegalArgumentException("compatibility must be at most 500 characters");
}
return fm;
}
}
}
Loading
Loading