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:
*
*
* - Static field reference: {@code com.example.MyProvider.INSTANCE}
*
- Class name: {@code com.example.MyProvider} (requires default
* constructor)
+ *
- Directory path: {@code /path/to/agents} (parent directory containing
+ * agent subdirectories, each with root_agent.yaml)
*
*
- * 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) {