diff --git a/maven_plugin/README.md b/maven_plugin/README.md index 37731ead9..e79934824 100644 --- a/maven_plugin/README.md +++ b/maven_plugin/README.md @@ -12,7 +12,7 @@ mvn google-adk:web -Dagents=com.example.MyAgentLoader.INSTANCE ### Parameters -- **`agents`** (required): Full class path to your AgentLoader implementation +- **`agents`** (required): Full class path to your AgentLoader implementation OR path to agent configuration directory - **`port`** (optional, default: 8000): Port for the web server - **`host`** (optional, default: localhost): Host address to bind to - **`hotReloading`** (optional, default: true): Whether to enable hot reloading @@ -195,6 +195,59 @@ Usage: mvn google-adk:web -Dagents=com.example.MultipleLoaders.ADVANCED ``` +## Config-Based Agents (Directory Path) + +For configuration-based agents using YAML files, you can provide a directory path instead of a class name. The plugin will automatically use `ConfigAgentLoader` to scan for agent directories. + +### Directory Structure + +Create a parent directory containing subdirectories, each representing an agent with a `root_agent.yaml` file: + +``` +my-agents/ +├── chat-assistant/ +│ └── root_agent.yaml +├── search-agent/ +│ └── root_agent.yaml +└── code-helper/ + ├── root_agent.yaml + └── another_agent.yaml +``` + +### Usage with Directory Path + +```bash +mvn google-adk:web -Dagents=my-agents +``` + +Or with absolute path: + +```bash +mvn google-adk:web -Dagents=/home/user/my-agents +``` + +### Hot Reloading for Config Agents + +When using config-based agents, hot reloading is enabled by default. The plugin will automatically detect changes to any YAML files within the agent directories and reload agents without restarting the server. + +To disable hot reloading: + +```bash +mvn google-adk:web -Dagents=my-agents -DhotReloading=false +``` + +### Example root_agent.yaml + +```yaml +name: "chat_assistant" +description: "A friendly chat assistant" +model: "gemini-2.0-flash" +instruction: | + You are a helpful and friendly assistant. + Answer questions clearly and concisely. + Be encouraging and positive in your responses. +``` + ## Web UI Once the server starts, open your browser to: diff --git a/maven_plugin/src/main/java/com/google/adk/maven/ConfigAgentLoader.java b/maven_plugin/src/main/java/com/google/adk/maven/ConfigAgentLoader.java new file mode 100644 index 000000000..d87bd4f9b --- /dev/null +++ b/maven_plugin/src/main/java/com/google/adk/maven/ConfigAgentLoader.java @@ -0,0 +1,249 @@ +/* + * Copyright 2025 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.maven; + +import static java.util.stream.Collectors.toList; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.ConfigAgentUtils; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import javax.annotation.concurrent.ThreadSafe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Configuration-based AgentLoader that loads agents from YAML configuration files. + * + *

This loader monitors a configured source directory for folders containing `root_agent.yaml` + * files and automatically reloads agents when the files change (if hot-reloading is enabled). + * + *

The loader treats each subdirectory with a `root_agent.yaml` file as an agent, using the + * folder name as the agent identifier. Agents are loaded lazily when first requested. + * + *

Directory structure expected: + * + *

+ * source-dir/
+ *   ├── agent1/
+ *   │   └── root_agent.yaml
+ *   ├── agent2/
+ *   │   └── root_agent.yaml
+ *   └── ...
+ * 
+ * + *

Hot-reloading can be disabled by setting hotReloadingEnabled to false. + * + *

TODO: Config agent features are not yet ready for public use. + */ +@ThreadSafe +class ConfigAgentLoader implements AgentLoader { + private static final Logger logger = LoggerFactory.getLogger(ConfigAgentLoader.class); + private static final String YAML_CONFIG_FILENAME = "root_agent.yaml"; + + private final boolean hotReloadingEnabled; + private final String sourceDir; + private final Map> agentSuppliers = new ConcurrentHashMap<>(); + private final ConfigAgentWatcher watcher; + private volatile boolean started = false; + + /** + * Creates a new ConfigAgentLoader. + * + * @param sourceDir The directory to scan for agent configuration files + * @param hotReloadingEnabled Controls whether hot-reloading is enabled + */ + public ConfigAgentLoader(String sourceDir, boolean hotReloadingEnabled) { + this.sourceDir = sourceDir; + this.hotReloadingEnabled = hotReloadingEnabled; + this.watcher = hotReloadingEnabled ? new ConfigAgentWatcher() : null; + + try { + discoverAgents(); + if (hotReloadingEnabled) { + start(); + } + } catch (IOException e) { + logger.error("Failed to initialize ConfigAgentLoader", e); + } + } + + /** + * Creates a new ConfigAgentLoader with hot-reloading enabled. + * + * @param sourceDir The directory to scan for agent configuration files + */ + public ConfigAgentLoader(String sourceDir) { + this(sourceDir, true); + } + + @Override + @Nonnull + public ImmutableList listAgents() { + return ImmutableList.copyOf(agentSuppliers.keySet()); + } + + @Override + public BaseAgent loadAgent(String name) { + Supplier supplier = agentSuppliers.get(name); + if (supplier == null) { + throw new NoSuchElementException("Agent not found: " + name); + } + return supplier.get(); + } + + /** + * Discovers available agents from the configured source directory and creates suppliers for them. + * + * @throws IOException if there's an error accessing the source directory + */ + private void discoverAgents() throws IOException { + if (sourceDir == null || sourceDir.isEmpty()) { + logger.info( + "Agent source directory not configured. ConfigAgentLoader will not discover any agents."); + return; + } + + Path sourcePath = Paths.get(sourceDir); + if (!Files.isDirectory(sourcePath)) { + logger.warn( + "Agent source directory does not exist: {}. ConfigAgentLoader will not discover any" + + " agents.", + sourcePath); + return; + } + + logger.info("Initial scan for YAML agents in: {}", sourcePath); + + try (Stream entries = Files.list(sourcePath)) { + for (Path agentDir : entries.collect(toList())) { + if (Files.isDirectory(agentDir)) { + Path yamlConfigPath = agentDir.resolve(YAML_CONFIG_FILENAME); + if (Files.exists(yamlConfigPath) && Files.isRegularFile(yamlConfigPath)) { + // Use the folder name as the agent identifier + String agentName = agentDir.getFileName().toString(); + logger.debug("Discovering YAML agent config: {}", yamlConfigPath); + + if (agentSuppliers.containsKey(agentName)) { + logger.warn( + "Duplicate agent name '{}' found in {}. Overwriting.", agentName, yamlConfigPath); + } + // Create a memoized supplier that will load the agent only when requested + agentSuppliers.put( + agentName, Suppliers.memoize(() -> loadAgentFromPath(yamlConfigPath))); + + // Register with watcher if hot-reloading is enabled + if (hotReloadingEnabled && watcher != null) { + watcher.watch(agentDir, agentDirPath -> updateAgentSupplier(agentDirPath)); + } + + logger.info("Discovered YAML agent '{}' from: {}", agentName, yamlConfigPath); + } + } + } + } + + logger.info("Initial YAML agent discovery complete. Found {} agents.", agentSuppliers.size()); + } + + /** + * Updates the agent supplier when a configuration changes. + * + * @param agentDirPath The path to the agent configuration directory + */ + private void updateAgentSupplier(Path agentDirPath) { + String agentName = agentDirPath.getFileName().toString(); + Path yamlConfigPath = agentDirPath.resolve(YAML_CONFIG_FILENAME); + + if (Files.exists(yamlConfigPath)) { + // File exists - create/update supplier + agentSuppliers.put(agentName, Suppliers.memoize(() -> loadAgentFromPath(yamlConfigPath))); + logger.info("Updated YAML agent supplier '{}' from: {}", agentName, yamlConfigPath); + } else { + // File deleted - remove supplier + agentSuppliers.remove(agentName); + logger.info("Removed YAML agent '{}' due to deleted config file", agentName); + } + } + + /** + * Loads an agent from the specified config path. + * + * @param yamlConfigPath The path to the YAML configuration file + * @return The loaded BaseAgent + * @throws RuntimeException if loading fails + */ + private BaseAgent loadAgentFromPath(Path yamlConfigPath) { + try { + logger.debug("Loading YAML agent from: {}", yamlConfigPath); + BaseAgent agent = ConfigAgentUtils.fromConfig(yamlConfigPath.toString()); + logger.info("Successfully loaded YAML agent '{}' from: {}", agent.name(), yamlConfigPath); + return agent; + } catch (Exception e) { + logger.error("Failed to load YAML agent from: {}", yamlConfigPath, e); + throw new RuntimeException("Failed to load agent from: " + yamlConfigPath, e); + } + } + + /** + * Starts the hot-loading service. Sets up file watching. + * + * @throws IOException if there's an error accessing the source directory + */ + private synchronized void start() throws IOException { + if (!hotReloadingEnabled || watcher == null) { + logger.info( + "Hot-reloading is disabled. YAML agents will be loaded once at startup and will not be" + + " monitored for changes."); + return; + } + + if (started) { + logger.warn("ConfigAgentLoader is already started"); + return; + } + + logger.info("Starting ConfigAgentLoader with file watching"); + watcher.start(); + started = true; + logger.info("ConfigAgentLoader started successfully with {} agents.", agentSuppliers.size()); + } + + /** Stops the hot-loading service. */ + public synchronized void stop() { + if (!started) { + return; + } + + logger.info("Stopping ConfigAgentLoader..."); + if (watcher != null) { + watcher.stop(); + } + started = false; + logger.info("ConfigAgentLoader stopped."); + } +} diff --git a/maven_plugin/src/main/java/com/google/adk/maven/ConfigAgentWatcher.java b/maven_plugin/src/main/java/com/google/adk/maven/ConfigAgentWatcher.java new file mode 100644 index 000000000..97dc34ddd --- /dev/null +++ b/maven_plugin/src/main/java/com/google/adk/maven/ConfigAgentWatcher.java @@ -0,0 +1,255 @@ +/* + * Copyright 2025 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.maven; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import javax.annotation.concurrent.ThreadSafe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * File watcher for monitoring changes to YAML configuration files in agent directories. + * + *

This class monitors individual agent folders containing `root_agent.yaml` files and triggers + * callbacks when files are created, modified, or deleted. + * + *

The watcher polls for changes at regular intervals rather than using native filesystem events + * for better cross-platform compatibility. + */ +@ThreadSafe +class ConfigAgentWatcher { + private static final Logger logger = LoggerFactory.getLogger(ConfigAgentWatcher.class); + + private final Map watchedFolders = new ConcurrentHashMap<>(); + private final Map> watchedYamlFiles = new ConcurrentHashMap<>(); + private final ScheduledExecutorService fileWatcher = Executors.newSingleThreadScheduledExecutor(); + private volatile boolean started = false; + + /** Callback interface for handling file change events. */ + @FunctionalInterface + interface ChangeCallback { + /** + * Called when a watched YAML file changes, is created, or is deleted. + * + * @param agentDirPath The path to the agent configuration directory + */ + void onConfigChanged(Path agentDirPath); + } + + /** + * Starts watching for file changes. + * + * @throws IllegalStateException if the watcher is already started + */ + synchronized void start() { + if (started) { + throw new IllegalStateException("ConfigAgentWatcher is already started"); + } + + logger.info("Starting ConfigAgentWatcher"); + fileWatcher.scheduleAtFixedRate(this::checkForChanges, 2, 2, TimeUnit.SECONDS); + started = true; + + Runtime.getRuntime().addShutdownHook(new Thread(this::stop)); + logger.info( + "ConfigAgentWatcher started successfully. Watching {} folders.", watchedFolders.size()); + } + + /** Stops the file watcher. */ + synchronized void stop() { + if (!started) { + return; + } + + logger.info("Stopping ConfigAgentWatcher..."); + fileWatcher.shutdown(); + try { + if (!fileWatcher.awaitTermination(5, TimeUnit.SECONDS)) { + fileWatcher.shutdownNow(); + } + } catch (InterruptedException e) { + fileWatcher.shutdownNow(); + Thread.currentThread().interrupt(); + } + started = false; + logger.info("ConfigAgentWatcher stopped."); + } + + /** + * Adds a folder to be watched for changes to any YAML files within it. + * + * @param agentDirPath The path to the agent configuration directory + * @param callback The callback to invoke when changes are detected + * @throws IllegalArgumentException if the folder doesn't exist + */ + void watch(Path agentDirPath, ChangeCallback callback) { + if (!Files.isDirectory(agentDirPath)) { + throw new IllegalArgumentException("Config folder does not exist: " + agentDirPath); + } + + watchedFolders.put(agentDirPath, callback); + + // Scan and track all YAML files in the directory + Map yamlFiles = scanYamlFiles(agentDirPath); + watchedYamlFiles.put(agentDirPath, yamlFiles); + + logger.debug("Now watching {} YAML files in agent folder: {}", yamlFiles.size(), agentDirPath); + } + + /** + * Scans a directory recursively for all YAML files and returns their last modified times. + * + * @param agentDirPath The directory to scan recursively + * @return A map of YAML file paths to their last modified times + */ + private Map scanYamlFiles(Path agentDirPath) { + Map yamlFiles = new HashMap<>(); + try (Stream files = Files.walk(agentDirPath)) { + files + .filter(Files::isRegularFile) + .filter( + path -> + path.toString().toLowerCase().endsWith(".yaml") + || path.toString().toLowerCase().endsWith(".yml")) + .forEach( + yamlFile -> { + long lastModified = getLastModified(yamlFile); + yamlFiles.put(yamlFile, lastModified); + logger.trace("Found YAML file: {} (modified: {})", yamlFile, lastModified); + }); + } catch (IOException e) { + logger.warn("Failed to scan YAML files in: {}", agentDirPath, e); + } + return yamlFiles; + } + + /** + * Returns whether the watcher is currently running. + * + * @return true if the watcher is started, false otherwise + */ + public boolean isStarted() { + return started; + } + + /** Checks all watched files for changes and triggers callbacks if needed. */ + private void checkForChanges() { + for (Map.Entry entry : new HashMap<>(watchedFolders).entrySet()) { + Path agentDirPath = entry.getKey(); + ChangeCallback callback = entry.getValue(); + + try { + checkDirectoryForChanges(agentDirPath, callback); + } catch (Exception e) { + logger.error("Error checking directory for changes: {}", agentDirPath, e); + } + } + } + + /** + * Checks a specific agent directory for YAML file changes. + * + * @param agentDirPath The agent directory to check + * @param callback The callback for this directory + */ + private void checkDirectoryForChanges(Path agentDirPath, ChangeCallback callback) { + if (!Files.isDirectory(agentDirPath)) { + // Directory was deleted + handleDirectoryDeleted(agentDirPath); + return; + } + + Map currentYamlFiles = watchedYamlFiles.get(agentDirPath); + if (currentYamlFiles == null) { + return; // No tracked files for this directory + } + + // Scan current YAML files in the directory + Map freshYamlFiles = scanYamlFiles(agentDirPath); + boolean hasChanges = false; + + // Check for new or modified files + for (Map.Entry freshEntry : freshYamlFiles.entrySet()) { + Path yamlFile = freshEntry.getKey(); + long currentModified = freshEntry.getValue(); + Long previousModified = currentYamlFiles.get(yamlFile); + + if (previousModified == null) { + // New file + logger.info("Detected new YAML file: {}", yamlFile); + hasChanges = true; + } else if (currentModified > previousModified) { + // Modified file + logger.info("Detected change in YAML file: {}", yamlFile); + hasChanges = true; + } + } + + // Check for deleted files + for (Path trackedFile : currentYamlFiles.keySet()) { + if (!freshYamlFiles.containsKey(trackedFile)) { + logger.info("Detected deleted YAML file: {}", trackedFile); + hasChanges = true; + } + } + + // Update tracked files and trigger callback if there were changes + if (hasChanges) { + watchedYamlFiles.put(agentDirPath, freshYamlFiles); + callback.onConfigChanged(agentDirPath); + } + } + + /** + * Handles the deletion of a watched agent directory. + * + * @param agentDirPath The path of the deleted agent directory + */ + private void handleDirectoryDeleted(Path agentDirPath) { + logger.info("Agent directory deleted: {}", agentDirPath); + ChangeCallback callback = watchedFolders.remove(agentDirPath); + watchedYamlFiles.remove(agentDirPath); + + if (callback != null) { + callback.onConfigChanged(agentDirPath); + } + } + + /** + * Gets the last modified time of a file, handling potential I/O errors. + * + * @param path The file path + * @return The last modified time in milliseconds, or 0 if there's an error + */ + private long getLastModified(Path path) { + try { + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + logger.warn("Could not get last modified time for: {}", path, e); + return 0; + } + } +} diff --git a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java index 1335668be..86308a844 100644 --- a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java +++ b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java @@ -22,6 +22,9 @@ import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import org.apache.maven.artifact.DependencyResolutionRequiredException; @@ -87,21 +90,26 @@ public class WebMojo extends AbstractMojo { private MavenProject project; /** - * Full class path to the AgentLoader instance. + * Full class path to the AgentLoader instance or path to agent configuration directory. * - *

This parameter specifies the AgentLoader implementation that will supply the agents for the - * web server. It can be specified in two formats: + *

This parameter specifies either: * *

* - *

Example: + *

When a directory path is provided, the plugin will use ConfigAgentLoader to scan for + * subdirectories containing {@code root_agent.yaml} files. + * + *

Examples: * *

{@code
    * mvn google-adk:web -Dagents=com.example.MyAgentLoader
+   * mvn google-adk:web -Dagents=/path/to/agents
    * }
*/ @Parameter(property = "agents") @@ -211,8 +219,8 @@ public void execute() throws MojoExecutionException, MojoFailureException { private void validateParameters() throws MojoFailureException { if (agents == null || agents.trim().isEmpty()) { throw new MojoFailureException( - "agents parameter is required. " - + "Usage: mvn google-adk:web -Dagents=com.example.MyProvider.INSTANCE"); + "agents parameter is required. Usage: mvn google-adk:web" + + " -Dagents=com.example.MyProvider.INSTANCE or -Dagents=/path/to/agents"); } if (port < 1 || port > 65535) { @@ -267,7 +275,14 @@ private URLClassLoader createProjectClassLoader() throws MojoExecutionException } private AgentLoader loadAgentProvider() throws MojoExecutionException { - // First, try to interpret as class.field syntax + // First, check if agents parameter is a directory path + Path agentsPath = Paths.get(agents); + if (Files.isDirectory(agentsPath)) { + getLog().info("Detected directory path, using ConfigAgentLoader: " + agents); + return new ConfigAgentLoader(agents, hotReloading); + } + + // Next, try to interpret as class.field syntax if (agents.contains(".")) { AgentLoader provider = tryLoadFromStaticField(); if (provider != null) {