From 1b31744d275d2101aef29a56b22afc3aad59ed88 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Thu, 20 Nov 2025 11:43:29 +0000 Subject: [PATCH] feat!: Remove hard dependency on MicroProfile Config from the core SDK (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default values will now be used by default. You can supply your own by providing a CDI bean with a higher priority. Also, there is a new 2a-java-sdk-microprofile-config with the previous MicroProfile Config capabilities. If used, this will allow MicroProfile Config configurations of the properties. The reference implementations use this new module Fixes #467 🦕 --- README.md | 40 +++-- .../database/jpa/JpaDatabaseTaskStore.java | 20 ++- .../META-INF/a2a-defaults.properties | 6 + integrations/microprofile-config/README.md | 148 ++++++++++++++++++ integrations/microprofile-config/pom.xml | 59 +++++++ .../MicroProfileConfigProvider.java | 78 +++++++++ .../MicroProfileConfigProviderTest.java | 102 ++++++++++++ .../src/test/resources/application.properties | 14 ++ pom.xml | 6 + reference/common/pom.xml | 4 + server-common/pom.xml | 4 - .../a2a/server/config/A2AConfigProvider.java | 35 +++++ .../config/DefaultValuesConfigProvider.java | 96 ++++++++++++ .../DefaultRequestHandler.java | 32 ++-- .../util/async/AsyncExecutorProducer.java | 36 ++++- .../META-INF/a2a-defaults.properties | 21 +++ 16 files changed, 666 insertions(+), 35 deletions(-) create mode 100644 extras/task-store-database-jpa/src/main/resources/META-INF/a2a-defaults.properties create mode 100644 integrations/microprofile-config/README.md create mode 100644 integrations/microprofile-config/pom.xml create mode 100644 integrations/microprofile-config/src/main/java/io/a2a/integrations/microprofile/MicroProfileConfigProvider.java create mode 100644 integrations/microprofile-config/src/test/java/io/a2a/integrations/microprofile/MicroProfileConfigProviderTest.java create mode 100644 integrations/microprofile-config/src/test/resources/application.properties create mode 100644 server-common/src/main/java/io/a2a/server/config/A2AConfigProvider.java create mode 100644 server-common/src/main/java/io/a2a/server/config/DefaultValuesConfigProvider.java create mode 100644 server-common/src/main/resources/META-INF/a2a-defaults.properties diff --git a/README.md b/README.md index f5228e0c0..45c0fa1d9 100644 --- a/README.md +++ b/README.md @@ -232,11 +232,22 @@ public class WeatherAgentExecutorProducer { } ``` -### 4. Configure Executor Settings (Optional) +### 4. Configuration System -The A2A Java SDK uses a dedicated executor for handling asynchronous operations like streaming subscriptions. By default, this executor is configured with a core pool size of 5 threads and a maximum pool size of 50 threads, optimized for I/O-bound operations. +The A2A Java SDK uses a flexible configuration system that works across different frameworks. -You can customize the executor settings in your `application.properties`: +**Default behavior:** Configuration values come from `META-INF/a2a-defaults.properties` files on the classpath (provided by core modules and extras). These defaults work out of the box without any additional setup. + +**Customizing configuration:** +- **Quarkus/MicroProfile Config users**: Add the [`microprofile-config`](integrations/microprofile-config/README.md) integration to override defaults via `application.properties`, environment variables, or system properties +- **Spring/other frameworks**: See the [integration module README](integrations/microprofile-config/README.md#custom-config-providers) for how to implement a custom `A2AConfigProvider` +- **Reference implementations**: Already include the MicroProfile Config integration + +#### Configuration Properties + +**Executor Settings** (Optional) + +The SDK uses a dedicated executor for async operations like streaming. Default: 5 core threads, 50 max threads. ```properties # Core thread pool size for the @Internal executor (default: 5) @@ -249,20 +260,23 @@ a2a.executor.max-pool-size=50 a2a.executor.keep-alive-seconds=60 ``` -**Why this matters:** -- **Streaming Performance**: The executor handles streaming subscriptions. Too few threads can cause timeouts under concurrent load. -- **Resource Management**: The dedicated executor prevents streaming operations from competing with the ForkJoinPool used by other async tasks. -- **Concurrency**: In production environments with high concurrent streaming requests, increase the pool sizes accordingly. +**Blocking Call Timeouts** (Optional) -**Default Configuration:** ```properties -# These are the defaults - no need to set unless you want different values -a2a.executor.core-pool-size=5 -a2a.executor.max-pool-size=50 -a2a.executor.keep-alive-seconds=60 +# Timeout for agent execution in blocking calls (default: 30 seconds) +a2a.blocking.agent.timeout.seconds=30 + +# Timeout for event consumption in blocking calls (default: 5 seconds) +a2a.blocking.consumption.timeout.seconds=5 ``` -**Note:** The reference server implementations automatically configure this executor. If you're creating a custom server integration, ensure you provide an `@Internal Executor` bean for optimal streaming performance. +**Why this matters:** +- **Streaming Performance**: The executor handles streaming subscriptions. Too few threads can cause timeouts under concurrent load. +- **Resource Management**: The dedicated executor prevents streaming operations from competing with the ForkJoinPool. +- **Concurrency**: In production with high concurrent streaming, increase pool sizes accordingly. +- **Agent Timeouts**: LLM-based agents may need longer timeouts (60-120s) compared to simple agents. + +**Note:** The reference server implementations (Quarkus-based) automatically include the MicroProfile Config integration, so properties work out of the box in `application.properties`. ## A2A Client diff --git a/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java b/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java index 44837ae85..edfbfaf69 100644 --- a/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java +++ b/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.time.Instant; +import jakarta.annotation.PostConstruct; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Event; @@ -14,10 +15,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.a2a.extras.common.events.TaskFinalizedEvent; +import io.a2a.server.config.A2AConfigProvider; import io.a2a.server.tasks.TaskStateProvider; import io.a2a.server.tasks.TaskStore; import io.a2a.spec.Task; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,9 +36,24 @@ public class JpaDatabaseTaskStore implements TaskStore, TaskStateProvider { Event taskFinalizedEvent; @Inject - @ConfigProperty(name = "a2a.replication.grace-period-seconds", defaultValue = "15") + A2AConfigProvider configProvider; + + /** + * Grace period for task finalization in replicated scenarios (seconds). + * After a task reaches a final state, this is the minimum time to wait before cleanup + * to allow replicated events to arrive and be processed. + *

+ * Property: {@code a2a.replication.grace-period-seconds}
+ * Default: 15
+ * Note: Property override requires a configurable {@link A2AConfigProvider} on the classpath. + */ long gracePeriodSeconds; + @PostConstruct + void initConfig() { + gracePeriodSeconds = Long.parseLong(configProvider.getValue("a2a.replication.grace-period-seconds")); + } + @Transactional @Override public void save(Task task) { diff --git a/extras/task-store-database-jpa/src/main/resources/META-INF/a2a-defaults.properties b/extras/task-store-database-jpa/src/main/resources/META-INF/a2a-defaults.properties new file mode 100644 index 000000000..c01c5e60a --- /dev/null +++ b/extras/task-store-database-jpa/src/main/resources/META-INF/a2a-defaults.properties @@ -0,0 +1,6 @@ +# A2A JPA Database Task Store Default Configuration + +# Grace period for task finalization in replicated scenarios (seconds) +# After a task reaches a final state, this is the minimum time to wait before cleanup +# to allow replicated events to arrive and be processed +a2a.replication.grace-period-seconds=15 diff --git a/integrations/microprofile-config/README.md b/integrations/microprofile-config/README.md new file mode 100644 index 000000000..501a0dd78 --- /dev/null +++ b/integrations/microprofile-config/README.md @@ -0,0 +1,148 @@ +# A2A Java SDK - MicroProfile Config Integration + +This optional integration module provides MicroProfile Config support for the A2A Java SDK configuration system. + +## Overview + +The A2A Java SDK core uses the `A2AConfigProvider` interface for configuration, with default values loaded from `META-INF/a2a-defaults.properties` files on the classpath. + +This module provides `MicroProfileConfigProvider`, which integrates with MicroProfile Config to allow configuration via: +- `application.properties` +- Environment variables +- System properties (`-D` flags) +- Custom ConfigSources + +## Quick Start + +### 1. Add Dependency + +```xml + + io.github.a2asdk + a2a-java-sdk-microprofile-config + ${io.a2a.sdk.version} + +``` + +### 2. Configure Properties + +Once the dependency is added, you can override any A2A configuration property: + +**application.properties:** +```properties +# Executor configuration +a2a.executor.core-pool-size=10 +a2a.executor.max-pool-size=100 + +# Timeout configuration +a2a.blocking.agent.timeout.seconds=60 +a2a.blocking.consumption.timeout.seconds=10 +``` + +**Environment variables:** +```bash +export A2A_EXECUTOR_CORE_POOL_SIZE=10 +export A2A_BLOCKING_AGENT_TIMEOUT_SECONDS=60 +``` + +**System properties:** +```bash +java -Da2a.executor.core-pool-size=10 -jar your-app.jar +``` + +## How It Works + +The `MicroProfileConfigProvider` implementation: + +1. **First tries MicroProfile Config** - Checks `application.properties`, environment variables, system properties, and custom ConfigSources +2. **Falls back to defaults** - If not found, uses values from `META-INF/a2a-defaults.properties` provided by core modules and extras +3. **Priority 50** - Can be overridden by custom providers with higher priority + +## Configuration Fallback Chain + +``` +MicroProfile Config Sources (application.properties, env vars, -D flags) + ↓ (not found?) +DefaultValuesConfigProvider + → Scans classpath for ALL META-INF/a2a-defaults.properties files + → Merges all discovered properties together + → Throws exception if duplicate keys found + ↓ (property exists?) +Return merged default value + ↓ (not found?) +IllegalArgumentException +``` + +**Note**: All `META-INF/a2a-defaults.properties` files (from server-common, extras modules, etc.) are loaded and merged together by `DefaultValuesConfigProvider` at startup. This is not a sequential fallback chain, but a single merged set of defaults. + +## Available Configuration Properties + +See the [main README](../../README.md#configuration-system) for a complete list of configuration properties. + +## Framework Compatibility + +This module works with any MicroProfile Config implementation: + +- **Quarkus** - Built-in MicroProfile Config support +- **Helidon** - Built-in MicroProfile Config support +- **Open Liberty** - Built-in MicroProfile Config support +- **WildFly/JBoss EAP** - Add `smallrye-config` dependency +- **Other Jakarta EE servers** - Add MicroProfile Config implementation + +## Custom Config Providers + +If you're using a different framework (Spring, Micronaut, etc.), you can implement your own `A2AConfigProvider`: + +```java +import io.a2a.server.config.A2AConfigProvider; +import io.a2a.server.config.DefaultValuesConfigProvider; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; + +@ApplicationScoped +@Alternative +@Priority(100) // Higher than MicroProfileConfigProvider's priority of 50 +public class OtherEnvironmentConfigProvider implements A2AConfigProvider { + + @Inject + Environment env; + + @Inject + DefaultValuesConfigProvider defaultValues; + + @Override + public String getValue(String name) { + String value = env.getProperty(name); + if (value != null) { + return value; + } + // Fallback to defaults + return defaultValues.getValue(name); + } + + @Override + public Optional getOptionalValue(String name) { + String value = env.getProperty(name); + if (value != null) { + return Optional.of(value); + } + return defaultValues.getOptionalValue(name); + } +} +``` + +## Implementation Details + +- **Package**: `io.a2a.integrations.microprofile` +- **Class**: `MicroProfileConfigProvider` +- **Priority**: 50 (can be overridden) +- **Scope**: `@ApplicationScoped` +- **Dependencies**: MicroProfile Config API, A2A SDK server-common + +## Reference Implementations + +The A2A Java SDK reference implementations (Quarkus-based) automatically include this integration module, so MicroProfile Config properties work out of the box. + +If you're building a custom server implementation, add this dependency to enable property-based configuration. diff --git a/integrations/microprofile-config/pom.xml b/integrations/microprofile-config/pom.xml new file mode 100644 index 000000000..29b9f750d --- /dev/null +++ b/integrations/microprofile-config/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 0.3.3.Beta1-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-microprofile-config + + jar + + A2A Java SDK - MicroProfile Config Integration + MicroProfile Config integration for A2A Java SDK - provides A2AConfigProvider implementation + + + + ${project.groupId} + a2a-java-sdk-server-common + + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + jakarta.inject + jakarta.inject-api + + + org.eclipse.microprofile.config + microprofile-config-api + + + org.slf4j + slf4j-api + + + + + io.quarkus + quarkus-arc + test + + + io.quarkus + quarkus-junit5 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + diff --git a/integrations/microprofile-config/src/main/java/io/a2a/integrations/microprofile/MicroProfileConfigProvider.java b/integrations/microprofile-config/src/main/java/io/a2a/integrations/microprofile/MicroProfileConfigProvider.java new file mode 100644 index 000000000..666c2d612 --- /dev/null +++ b/integrations/microprofile-config/src/main/java/io/a2a/integrations/microprofile/MicroProfileConfigProvider.java @@ -0,0 +1,78 @@ +package io.a2a.integrations.microprofile; + +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; + +import io.a2a.server.config.A2AConfigProvider; +import io.a2a.server.config.DefaultValuesConfigProvider; +import org.eclipse.microprofile.config.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * MicroProfile Config-based implementation of {@link A2AConfigProvider}. + *

+ * This provider integrates with MicroProfile Config (used by Quarkus and other Jakarta EE runtimes) + * to allow configuration via standard sources: + *

+ *

+ * Falls back to {@link DefaultValuesConfigProvider} when a configuration value is not found + * in MicroProfile Config, ensuring that default values from {@code META-INF/a2a-defaults.properties} + * are always available. + *

+ * This provider is automatically enabled with {@code @Priority(50)}, but can be overridden by + * custom providers with higher priority. + *

+ * To use this provider, add the {@code a2a-java-sdk-microprofile-config} dependency to your project. + */ +@ApplicationScoped +@Alternative +@Priority(50) +public class MicroProfileConfigProvider implements A2AConfigProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(MicroProfileConfigProvider.class); + + @Inject + Config mpConfig; + + @Inject + DefaultValuesConfigProvider defaultValues; + + @Override + public String getValue(String name) { + Optional value = mpConfig.getOptionalValue(name, String.class); + if (value.isPresent()) { + LOGGER.trace("Config value '{}' = '{}' (from MicroProfile Config)", name, value.get()); + return value.get(); + } + + // Fallback to defaults + String defaultValue = defaultValues.getValue(name); + LOGGER.trace("Config value '{}' = '{}' (from DefaultValuesConfigProvider)", name, defaultValue); + return defaultValue; + } + + @Override + public Optional getOptionalValue(String name) { + Optional value = mpConfig.getOptionalValue(name, String.class); + if (value.isPresent()) { + LOGGER.trace("Optional config value '{}' = '{}' (from MicroProfile Config)", name, value.get()); + return value; + } + + // Fallback to defaults + Optional defaultValue = defaultValues.getOptionalValue(name); + LOGGER.trace("Optional config value '{}' = '{}' (from DefaultValuesConfigProvider)", + name, defaultValue.orElse("")); + return defaultValue; + } +} diff --git a/integrations/microprofile-config/src/test/java/io/a2a/integrations/microprofile/MicroProfileConfigProviderTest.java b/integrations/microprofile-config/src/test/java/io/a2a/integrations/microprofile/MicroProfileConfigProviderTest.java new file mode 100644 index 000000000..1c245baf7 --- /dev/null +++ b/integrations/microprofile-config/src/test/java/io/a2a/integrations/microprofile/MicroProfileConfigProviderTest.java @@ -0,0 +1,102 @@ +package io.a2a.integrations.microprofile; + +import io.a2a.server.config.A2AConfigProvider; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CDI-based test to verify that MicroProfileConfigProvider is properly selected + * and works correctly with MicroProfile Config and fallback to defaults. + */ +@QuarkusTest +public class MicroProfileConfigProviderTest { + + @Inject + A2AConfigProvider configProvider; + + @Test + public void testIsMicroProfileConfigProvider() { + // Verify that when microprofile-config module is on classpath, + // the injected A2AConfigProvider is the MicroProfile implementation + assertInstanceOf(MicroProfileConfigProvider.class, configProvider, + "A2AConfigProvider should be MicroProfileConfigProvider when module is present"); + } + + @Test + public void testGetValueFromMicroProfileConfig() { + // Test that values from application.properties override defaults + // The test application.properties sets a2a.executor.core-pool-size=15 + String value = configProvider.getValue("a2a.executor.core-pool-size"); + assertEquals("15", value, "Should get value from MicroProfile Config (application.properties)"); + } + + @Test + public void testGetValueFallbackToDefaults() { + // Test that values not in application.properties fall back to META-INF/a2a-defaults.properties + // a2a.executor.max-pool-size is not in test application.properties, so should use default + String value = configProvider.getValue("a2a.executor.max-pool-size"); + assertEquals("50", value, "Should fall back to default value from META-INF/a2a-defaults.properties"); + } + + @Test + public void testGetValueAnotherDefault() { + // Test another default property to ensure fallback works + String value = configProvider.getValue("a2a.executor.keep-alive-seconds"); + assertEquals("60", value, "Should fall back to default value"); + } + + @Test + public void testGetOptionalValueFromMicroProfileConfig() { + // Test optional value that exists in application.properties + Optional value = configProvider.getOptionalValue("a2a.executor.core-pool-size"); + assertTrue(value.isPresent(), "Optional value should be present"); + assertEquals("15", value.get(), "Should get overridden value from MicroProfile Config"); + } + + @Test + public void testGetOptionalValueFallbackToDefaults() { + // Test optional value that falls back to defaults + Optional value = configProvider.getOptionalValue("a2a.executor.max-pool-size"); + assertTrue(value.isPresent(), "Optional value should be present from defaults"); + assertEquals("50", value.get(), "Should get default value"); + } + + @Test + public void testGetOptionalValueNotFound() { + // Test optional value that doesn't exist anywhere + Optional value = configProvider.getOptionalValue("non.existent.property"); + assertFalse(value.isPresent(), "Optional value should be empty for non-existent property"); + } + + @Test + public void testGetValueThrowsForNonExistent() { + // Test that required getValue() throws for non-existent property + assertThrows(IllegalArgumentException.class, + () -> configProvider.getValue("non.existent.property"), + "Should throw IllegalArgumentException for non-existent required property"); + } + + @Test + public void testSystemPropertyOverride() { + // System properties should have higher priority than application.properties + // Set a system property and verify it's used + String originalValue = System.getProperty("a2a.test.system.property"); + try { + System.setProperty("a2a.test.system.property", "from-system-property"); + String value = configProvider.getValue("a2a.test.system.property"); + assertEquals("from-system-property", value, + "System property should override application.properties"); + } finally { + if (originalValue != null) { + System.setProperty("a2a.test.system.property", originalValue); + } else { + System.clearProperty("a2a.test.system.property"); + } + } + } +} diff --git a/integrations/microprofile-config/src/test/resources/application.properties b/integrations/microprofile-config/src/test/resources/application.properties new file mode 100644 index 000000000..a79c9b843 --- /dev/null +++ b/integrations/microprofile-config/src/test/resources/application.properties @@ -0,0 +1,14 @@ +# Test configuration for MicroProfileConfigProviderTest +# This overrides the default value to verify MicroProfile Config integration works + +# Override default value (default is 5) +a2a.executor.core-pool-size=15 + +# Note: a2a.executor.max-pool-size is NOT set here to test fallback to defaults +# Default value should be 50 from META-INF/a2a-defaults.properties + +# Exclude beans that aren't needed for config testing +quarkus.arc.exclude-types=io.a2a.server.requesthandlers.*,io.a2a.server.agentexecution.*,io.a2a.server.tasks.*,io.a2a.server.events.*,io.a2a.server.util.* + +# Property that will be overridden by a system property +a2a.test.system.property=from-application-properties \ No newline at end of file diff --git a/pom.xml b/pom.xml index 98dbe8bf4..eba4b8985 100644 --- a/pom.xml +++ b/pom.xml @@ -125,6 +125,11 @@ a2a-java-sdk-server-common ${project.version} + + ${project.groupId} + a2a-java-sdk-microprofile-config + ${project.version} + ${project.groupId} a2a-java-extras-common @@ -445,6 +450,7 @@ extras/push-notification-config-store-database-jpa extras/queue-manager-replicated http-client + integrations/microprofile-config reference/common reference/grpc reference/jsonrpc diff --git a/reference/common/pom.xml b/reference/common/pom.xml index 64e15bcde..dcd5781f6 100644 --- a/reference/common/pom.xml +++ b/reference/common/pom.xml @@ -49,6 +49,10 @@ org.slf4j slf4j-api + + ${project.groupId} + a2a-java-sdk-microprofile-config + io.quarkus quarkus-junit5 diff --git a/server-common/pom.xml b/server-common/pom.xml index 1476e17a6..f14b471a8 100644 --- a/server-common/pom.xml +++ b/server-common/pom.xml @@ -91,10 +91,6 @@ logback-classic test - - org.eclipse.microprofile.config - microprofile-config-api - diff --git a/server-common/src/main/java/io/a2a/server/config/A2AConfigProvider.java b/server-common/src/main/java/io/a2a/server/config/A2AConfigProvider.java new file mode 100644 index 000000000..ccd442e1c --- /dev/null +++ b/server-common/src/main/java/io/a2a/server/config/A2AConfigProvider.java @@ -0,0 +1,35 @@ +package io.a2a.server.config; + +import java.util.Optional; + +/** + * Configuration provider interface for A2A SDK configuration values. + *

+ * Implementations can obtain configuration from various sources: + *

+ *

+ * All configuration values are returned as strings. Consumers are responsible for type conversion. + */ +public interface A2AConfigProvider { + + /** + * Get a required configuration value. + * + * @param name the configuration property name + * @return the configuration value + * @throws IllegalArgumentException if the configuration value is not found + */ + String getValue(String name); + + /** + * Get an optional configuration value. + * + * @param name the configuration property name + * @return an Optional containing the value if present, empty otherwise + */ + Optional getOptionalValue(String name); +} diff --git a/server-common/src/main/java/io/a2a/server/config/DefaultValuesConfigProvider.java b/server-common/src/main/java/io/a2a/server/config/DefaultValuesConfigProvider.java new file mode 100644 index 000000000..f2375ae57 --- /dev/null +++ b/server-common/src/main/java/io/a2a/server/config/DefaultValuesConfigProvider.java @@ -0,0 +1,96 @@ +package io.a2a.server.config; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default configuration provider that loads values from {@code META-INF/a2a-defaults.properties} + * files on the classpath. + *

+ * Each module (server-common, extras, etc.) can contribute a {@code META-INF/a2a-defaults.properties} + * file with default configuration values. All files are discovered and merged at startup. + *

+ * If duplicate keys are found across different properties files, initialization will fail with + * an exception to prevent ambiguous configuration. + */ +@ApplicationScoped +public class DefaultValuesConfigProvider implements A2AConfigProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultValuesConfigProvider.class); + private static final String DEFAULTS_RESOURCE = "META-INF/a2a-defaults.properties"; + + private final Map defaults = new HashMap<>(); + + @PostConstruct + void init() { + loadDefaultsFromClasspath(); + } + + private void loadDefaultsFromClasspath() { + try { + Enumeration resources = Thread.currentThread() + .getContextClassLoader() + .getResources(DEFAULTS_RESOURCE); + + Map sourceTracker = new HashMap<>(); // Track which file each key came from + + while (resources.hasMoreElements()) { + URL url = resources.nextElement(); + LOGGER.debug("Loading A2A defaults from: {}", url); + + Properties props = new Properties(); + try (InputStream is = url.openStream()) { + props.load(is); + + // Check for duplicates and merge + for (String key : props.stringPropertyNames()) { + String value = props.getProperty(key); + String existingSource = sourceTracker.get(key); + + if (existingSource != null) { + throw new IllegalStateException(String.format( + "Duplicate configuration key '%s' found in multiple a2a-defaults.properties files: %s and %s", + key, existingSource, url)); + } + + defaults.put(key, value); + sourceTracker.put(key, url.toString()); + LOGGER.trace("Loaded default: {} = {}", key, value); + } + } + } + + LOGGER.info("Loaded {} A2A default configuration values from {} resource(s)", + defaults.size(), sourceTracker.values().stream().distinct().count()); + + } catch (IOException e) { + throw new RuntimeException("Failed to load A2A default configuration from classpath", e); + } + } + + @Override + public String getValue(String name) { + String value = defaults.get(name); + if (value == null) { + throw new IllegalArgumentException("No default configuration value found for: " + name); + } + return value; + } + + @Override + public Optional getOptionalValue(String name) { + return Optional.ofNullable(defaults.get(name)); + } +} diff --git a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java index a93b6238a..577e571c9 100644 --- a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java @@ -57,7 +57,8 @@ import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; import io.a2a.spec.UnsupportedOperationError; -import org.eclipse.microprofile.config.inject.ConfigProperty; +import io.a2a.server.config.A2AConfigProvider; +import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,24 +67,29 @@ public class DefaultRequestHandler implements RequestHandler { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultRequestHandler.class); + @Inject + A2AConfigProvider configProvider; + /** * Timeout in seconds to wait for agent execution to complete in blocking calls. * This allows slow agents (LLM-based, data processing, external APIs) sufficient time. - * Configurable via: a2a.blocking.agent.timeout.seconds - * Default: 30 seconds + *

+ * Property: {@code a2a.blocking.agent.timeout.seconds}
+ * Default: 30 seconds
+ * Note: Property override requires a configurable {@link A2AConfigProvider} on the classpath + * (e.g., MicroProfileConfigProvider in reference implementations). */ - @Inject - @ConfigProperty(name = "a2a.blocking.agent.timeout.seconds", defaultValue = "30") int agentCompletionTimeoutSeconds; /** * Timeout in seconds to wait for event consumption to complete in blocking calls. * This ensures all events are processed and persisted before returning to client. - * Configurable via: a2a.blocking.consumption.timeout.seconds - * Default: 5 seconds + *

+ * Property: {@code a2a.blocking.consumption.timeout.seconds}
+ * Default: 5 seconds
+ * Note: Property override requires a configurable {@link A2AConfigProvider} on the classpath + * (e.g., MicroProfileConfigProvider in reference implementations). */ - @Inject - @ConfigProperty(name = "a2a.blocking.consumption.timeout.seconds", defaultValue = "5") int consumptionCompletionTimeoutSeconds; private final AgentExecutor agentExecutor; @@ -115,6 +121,14 @@ public DefaultRequestHandler(AgentExecutor agentExecutor, TaskStore taskStore, this.requestContextBuilder = () -> new SimpleRequestContextBuilder(taskStore, false); } + @PostConstruct + void initConfig() { + agentCompletionTimeoutSeconds = Integer.parseInt( + configProvider.getValue("a2a.blocking.agent.timeout.seconds")); + consumptionCompletionTimeoutSeconds = Integer.parseInt( + configProvider.getValue("a2a.blocking.consumption.timeout.seconds")); + } + /** * For testing */ diff --git a/server-common/src/main/java/io/a2a/server/util/async/AsyncExecutorProducer.java b/server-common/src/main/java/io/a2a/server/util/async/AsyncExecutorProducer.java index 49e69f99e..d85cd4de3 100644 --- a/server-common/src/main/java/io/a2a/server/util/async/AsyncExecutorProducer.java +++ b/server-common/src/main/java/io/a2a/server/util/async/AsyncExecutorProducer.java @@ -14,7 +14,7 @@ import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; +import io.a2a.server.config.A2AConfigProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,22 +23,44 @@ public class AsyncExecutorProducer { private static final Logger LOGGER = LoggerFactory.getLogger(AsyncExecutorProducer.class); - @Inject // Needed to work in standard Jakarta runtimes (Quarkus skips this) - @ConfigProperty(name = "a2a.executor.core-pool-size", defaultValue = "5") + @Inject + A2AConfigProvider configProvider; + + /** + * Core pool size for async agent execution thread pool. + *

+ * Property: {@code a2a.executor.core-pool-size}
+ * Default: 5
+ * Note: Property override requires a configurable {@link A2AConfigProvider} on the classpath. + */ int corePoolSize; - @Inject // Needed to work in standard Jakarta runtimes (Quarkus skips this) - @ConfigProperty(name = "a2a.executor.max-pool-size", defaultValue = "50") + /** + * Maximum pool size for async agent execution thread pool. + *

+ * Property: {@code a2a.executor.max-pool-size}
+ * Default: 50
+ * Note: Property override requires a configurable {@link A2AConfigProvider} on the classpath. + */ int maxPoolSize; - @Inject // Needed to work in standard Jakarta runtimes (Quarkus skips this) - @ConfigProperty(name = "a2a.executor.keep-alive-seconds", defaultValue = "60") + /** + * Keep-alive time for idle threads (seconds). + *

+ * Property: {@code a2a.executor.keep-alive-seconds}
+ * Default: 60
+ * Note: Property override requires a configurable {@link A2AConfigProvider} on the classpath. + */ long keepAliveSeconds; private ExecutorService executor; @PostConstruct public void init() { + corePoolSize = Integer.parseInt(configProvider.getValue("a2a.executor.core-pool-size")); + maxPoolSize = Integer.parseInt(configProvider.getValue("a2a.executor.max-pool-size")); + keepAliveSeconds = Long.parseLong(configProvider.getValue("a2a.executor.keep-alive-seconds")); + LOGGER.info("Initializing async executor: corePoolSize={}, maxPoolSize={}, keepAliveSeconds={}", corePoolSize, maxPoolSize, keepAliveSeconds); diff --git a/server-common/src/main/resources/META-INF/a2a-defaults.properties b/server-common/src/main/resources/META-INF/a2a-defaults.properties new file mode 100644 index 000000000..280fd943b --- /dev/null +++ b/server-common/src/main/resources/META-INF/a2a-defaults.properties @@ -0,0 +1,21 @@ +# A2A SDK Default Configuration Values +# These values are used when no other configuration source provides them + +# DefaultRequestHandler - Blocking call timeouts +# Timeout for agent execution to complete (seconds) +# Increase for slow agents: LLM-based, data processing, external APIs +a2a.blocking.agent.timeout.seconds=30 + +# Timeout for event consumption/persistence to complete (seconds) +# Ensures TaskStore is fully updated before returning to client +a2a.blocking.consumption.timeout.seconds=5 + +# AsyncExecutorProducer - Thread pool configuration +# Core pool size for async agent execution +a2a.executor.core-pool-size=5 + +# Maximum pool size for async agent execution +a2a.executor.max-pool-size=50 + +# Keep-alive time for idle threads (seconds) +a2a.executor.keep-alive-seconds=60