Repository navigation
feat(starters): add AgentBuilderCustomizer and auto-assembly extensions #2488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| /* | ||
| * Copyright 2024-2026 the original author or authors. | ||
| * | ||
| * 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 io.agentscope.spring.boot; | ||
|
|
||
| import io.agentscope.core.ReActAgent; | ||
| import java.util.function.Consumer; | ||
|
|
||
| /** | ||
| * Customizer for {@link ReActAgent.Builder}. | ||
| * | ||
| * <p>Example usage: | ||
| * | ||
| * <pre>{@code | ||
| * @Bean | ||
| * public AgentBuilderCustomizer agentBuilderCustomizer() { | ||
| * return builder -> builder.middleware(new MyMiddleware()); | ||
| * } | ||
| * }</pre> | ||
| * | ||
| * @see AgentscopeAutoConfiguration#agentscopeReActAgent | ||
| */ | ||
| @FunctionalInterface | ||
| public interface AgentBuilderCustomizer extends Consumer<ReActAgent.Builder> { | ||
|
|
||
| /** | ||
| * Customize the {@link ReActAgent.Builder}. | ||
| * | ||
| * @param builder the builder to customize | ||
| */ | ||
| void customize(ReActAgent.Builder builder); | ||
|
|
||
| /** | ||
| * Accept and invoke the given builder. | ||
| * | ||
| * @param builder the builder to customize | ||
| */ | ||
| @Override | ||
| default void accept(ReActAgent.Builder builder) { | ||
| this.customize(builder); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,10 +18,16 @@ | |
| import io.agentscope.core.ReActAgent; | ||
| import io.agentscope.core.memory.InMemoryMemory; | ||
| import io.agentscope.core.memory.Memory; | ||
| import io.agentscope.core.middleware.MiddlewareBase; | ||
| import io.agentscope.core.model.Model; | ||
| import io.agentscope.core.permission.PermissionContextState; | ||
| import io.agentscope.core.tool.Toolkit; | ||
| import io.agentscope.spring.boot.properties.AgentProperties; | ||
| import io.agentscope.spring.boot.properties.AgentscopeProperties; | ||
| import java.util.List; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.beans.factory.ObjectProvider; | ||
| import org.springframework.beans.factory.config.ConfigurableBeanFactory; | ||
| import org.springframework.boot.autoconfigure.AutoConfiguration; | ||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; | ||
|
|
@@ -31,6 +37,8 @@ | |
| import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Scope; | ||
| import org.springframework.core.Ordered; | ||
| import org.springframework.core.annotation.Order; | ||
|
|
||
| /** | ||
| * Spring Boot auto-configuration that exposes default Memory, Toolkit and ReActAgent beans for | ||
|
|
@@ -51,12 +59,35 @@ | |
| * sys-prompt: "You are a helpful AI assistant." | ||
| * max-iters: 10 | ||
| * }</pre> | ||
| * | ||
| * <p>In addition to the core beans, this configuration provides the following | ||
| * conveniences when {@code agentscope.agent.enabled=true}: | ||
| * | ||
| * <ul> | ||
| * <li><b>Middleware auto-assembly</b> — every {@link MiddlewareBase} bean | ||
| * is auto-injected into the agent builder via an | ||
| * {@link AgentBuilderCustomizer}, ordered by | ||
| * {@link org.springframework.core.annotation.Order @Order}. Opt out with | ||
| * {@code agentscope.agent.auto-assemble-middleware=false}.</li> | ||
| * <li><b>PermissionContextState auto-injection</b> — if exactly one | ||
| * {@link PermissionContextState} bean exists in the context, it is | ||
| * auto-applied to the agent builder. No-op when absent; a warning is | ||
| * logged when ambiguous (two or more beans).</li> | ||
| * </ul> | ||
| * | ||
| * <p>Both conveniences are implemented as {@link AgentBuilderCustomizer} beans ordered with | ||
| * {@link Ordered#HIGHEST_PRECEDENCE}, so a user-defined {@code AgentBuilderCustomizer} | ||
| * <b>without an explicit {@code @Order}</b> (and therefore defaulting to | ||
| * {@link Ordered#LOWEST_PRECEDENCE}) runs afterwards and can override them. A user customizer | ||
| * that sets its own small {@code @Order} can run earlier instead. | ||
| */ | ||
| @AutoConfiguration | ||
| @EnableConfigurationProperties(AgentscopeProperties.class) | ||
| @ConditionalOnClass(ReActAgent.class) | ||
| public class AgentscopeAutoConfiguration { | ||
|
|
||
| private static final Logger logger = LoggerFactory.getLogger(AgentscopeAutoConfiguration.class); | ||
|
|
||
| /** | ||
| * Default Memory implementation backed by InMemoryMemory. | ||
| * | ||
|
|
@@ -105,14 +136,109 @@ public Toolkit agentscopeToolkit() { | |
| @ConditionalOnBean(Model.class) | ||
| @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") | ||
| public ReActAgent agentscopeReActAgent( | ||
| Model model, Memory memory, Toolkit toolkit, AgentscopeProperties properties) { | ||
| Model model, | ||
| Memory memory, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [INFO] The injected |
||
| Toolkit toolkit, | ||
| AgentscopeProperties properties, | ||
| ObjectProvider<AgentBuilderCustomizer> customizers) { | ||
| AgentProperties config = properties.getAgent(); | ||
| return ReActAgent.builder() | ||
| .name(config.getName()) | ||
| .sysPrompt(config.getSysPrompt()) | ||
| .model(model) | ||
| .toolkit(toolkit) | ||
| .maxIters(config.getMaxIters()) | ||
| .build(); | ||
| ReActAgent.Builder builder = | ||
| ReActAgent.builder() | ||
| .name(config.getName()) | ||
| .sysPrompt(config.getSysPrompt()) | ||
| .model(model) | ||
| .toolkit(toolkit) | ||
| .maxIters(config.getMaxIters()); | ||
| customizers.orderedStream().forEach(c -> c.customize(builder)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because this customizer is ordered at |
||
| return builder.build(); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------ | ||
| // Middleware auto-assembly | ||
| // ------------------------------------------------------------------ | ||
|
|
||
| /** | ||
| * Auto-injects all {@link MiddlewareBase} beans into the agent builder, | ||
| * ordered by {@link org.springframework.core.annotation.Order @Order}. | ||
| * | ||
| * <p>Ordered at {@link Ordered#HIGHEST_PRECEDENCE}{@code + 10} — before the permission and | ||
| * hook customizers, and before any user-defined {@link AgentBuilderCustomizer}. | ||
| * | ||
| * <p><b>Sharing contract:</b> a {@code MiddlewareBase} bean is a singleton, and every agent | ||
| * built from this auto-configuration receives the same instance. Middleware must therefore be | ||
| * stateless / thread-safe — keep per-request state in {@code RuntimeContext}, never in | ||
| * instance fields. | ||
| * | ||
| * <p>Disable with {@code agentscope.agent.auto-assemble-middleware=false} when middleware is | ||
| * wired manually, to avoid attaching the same middleware twice. To replace just the assembly | ||
| * logic, shadow the {@code middlewareAutoCustomizer} bean by name. | ||
| */ | ||
| @Bean | ||
| @Order(Ordered.HIGHEST_PRECEDENCE + 10) | ||
| @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") | ||
| @ConditionalOnProperty( | ||
| prefix = "agentscope.agent", | ||
| name = "auto-assemble-middleware", | ||
| havingValue = "true", | ||
| matchIfMissing = true) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Please add |
||
| @ConditionalOnMissingBean(name = "middlewareAutoCustomizer") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [INFO] All three built-in customizers back off only by exact bean name ( |
||
| public AgentBuilderCustomizer middlewareAutoCustomizer( | ||
| ObjectProvider<MiddlewareBase> middlewares) { | ||
| return builder -> { | ||
| List<MiddlewareBase> beans = middlewares.orderedStream().toList(); | ||
| if (!beans.isEmpty()) { | ||
| if (logger.isDebugEnabled()) { | ||
| logger.debug( | ||
| "Auto-assembled {} MiddlewareBase bean class(es): {}", | ||
| beans.size(), | ||
| beans.stream().map(b -> b.getClass().getSimpleName()).toList()); | ||
| } | ||
| beans.forEach(builder::middleware); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------ | ||
| // PermissionContextState auto-injection | ||
| // ------------------------------------------------------------------ | ||
|
|
||
| /** | ||
| * If exactly one {@link PermissionContextState} bean exists in the context, | ||
| * auto-applies it to the agent builder. No-op when no | ||
| * {@code PermissionContextState} bean is present. | ||
| * | ||
| * <p>When more than one {@code PermissionContextState} bean is present the context is | ||
| * ambiguous, so nothing is injected and a warning is logged — the permission engine decides | ||
| * allow/approve/deny, so silently picking one would be a security-relevant choice. | ||
| * | ||
| * <p>Ordered at {@link Ordered#HIGHEST_PRECEDENCE}{@code + 20} — after middleware assembly | ||
| * but still before the hook customizer and any user-defined {@link AgentBuilderCustomizer}. | ||
| * | ||
| * <p>Backs off when a bean named {@code permissionContextAutoCustomizer} already exists | ||
| * ({@code @ConditionalOnMissingBean(name = ...)}), so supplying one disables or replaces this | ||
| * auto-injection. | ||
| */ | ||
| @Bean | ||
| @Order(Ordered.HIGHEST_PRECEDENCE + 20) | ||
| @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") | ||
| @ConditionalOnMissingBean(name = "permissionContextAutoCustomizer") | ||
| public AgentBuilderCustomizer permissionContextAutoCustomizer( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ObjectProvider<PermissionContextState> permissionContext) { | ||
| return builder -> { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Also note the ordering guarantee is subtler than the javadoc suggests: |
||
| List<PermissionContextState> contexts = permissionContext.orderedStream().toList(); | ||
| if (contexts.size() == 1) { | ||
| builder.permissionContext(contexts.get(0)); | ||
| } else if (contexts.size() > 1) { | ||
| logger.warn( | ||
| "Found {} PermissionContextState beans; refusing to auto-inject an" | ||
| + " ambiguous permission context. Declare exactly one bean, or" | ||
| + " apply it via a user-defined AgentBuilderCustomizer.", | ||
| contexts.size()); | ||
| } else { | ||
| logger.debug( | ||
| "No PermissionContextState bean present;" | ||
| + " skipping permission context auto-injection"); | ||
| } | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,89 @@ | ||||||||||
| /* | ||||||||||
| * Copyright 2024-2026 the original author or authors. | ||||||||||
| * | ||||||||||
| * 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 io.agentscope.spring.boot; | ||||||||||
|
|
||||||||||
| import io.agentscope.core.hook.Hook; | ||||||||||
| import java.util.List; | ||||||||||
| import org.slf4j.Logger; | ||||||||||
| import org.slf4j.LoggerFactory; | ||||||||||
| import org.springframework.beans.factory.ObjectProvider; | ||||||||||
| import org.springframework.boot.autoconfigure.AutoConfiguration; | ||||||||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; | ||||||||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; | ||||||||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | ||||||||||
| import org.springframework.context.annotation.Bean; | ||||||||||
| import org.springframework.core.Ordered; | ||||||||||
| import org.springframework.core.annotation.Order; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Auto-configuration for {@link Hook} auto-assembly. | ||||||||||
| * | ||||||||||
| * <p>Isolated from {@link AgentscopeAutoConfiguration} because {@link Hook} and | ||||||||||
| * {@link io.agentscope.core.hook.HookEvent} are | ||||||||||
| * {@link Deprecated @Deprecated} for removal since 2.0.0. This class will be | ||||||||||
| * removed together with the Hook API. | ||||||||||
| * | ||||||||||
| * <p><b>Opt-in:</b> hook auto-attach is disabled by default because {@link Hook} is deprecated | ||||||||||
| * for removal. Enable it with {@code agentscope.agent.auto-assemble-hooks=true}; when enabled, | ||||||||||
| * every {@link Hook} bean is attached to the agent builder, so applications that also attach | ||||||||||
| * hooks manually must remove the manual attachment to avoid registering the same hook twice. | ||||||||||
| * | ||||||||||
| * <p>The injected hooks are ordered by | ||||||||||
| * {@link org.springframework.core.annotation.Order @Order}. The customizer | ||||||||||
| * itself is ordered at {@link Ordered#HIGHEST_PRECEDENCE}{@code + 30} so that it | ||||||||||
| * runs after the built-in middleware and permission customizers but still before | ||||||||||
| * any user-defined {@link AgentBuilderCustomizer}. | ||||||||||
| */ | ||||||||||
| @AutoConfiguration | ||||||||||
| @ConditionalOnClass(Hook.class) | ||||||||||
| @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") | ||||||||||
| @SuppressWarnings("deprecation") | ||||||||||
| public class HookAutoConfiguration { | ||||||||||
|
|
||||||||||
| private static final Logger logger = LoggerFactory.getLogger(HookAutoConfiguration.class); | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Auto-injects all {@link Hook} beans into the agent builder, ordered by | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Isolating the deprecated
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detailed review — every finding is valid. Here is how each was addressed in [Critical]
|
||||||||||
| Check | Result |
|---|---|
mvn spotless:apply |
Pass |
mvn test -pl .../agentscope-spring-boot-starter |
Pass — 12 run, 0 failures, 0 errors |
| Public API change | Additive only; no customizer beans → behaviour unchanged |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agentscope.agent.enabled must be set explicitly for this class to apply (havingValue="true" without matchIfMissing), while AgentProperties.enabled defaults to true. The mismatch is pre-existing for the agent bean, but the new auto-assemble-hooks/auto-assemble-middleware flags inherit the same trap: a user who sets only auto-assemble-hooks=false and never sets enabled gets the default behaviour silently. Consider matchIfMissing = true on the enabled condition (to match the field default) or aligning the docs on "explicitly required".
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,24 @@ public class AgentProperties { | |
| */ | ||
| private int maxIters = 10; | ||
|
|
||
| /** | ||
| * Whether every {@code MiddlewareBase} bean is auto-injected into the agent builder. | ||
| * | ||
| * <p>Default {@code true}. Auto-assembly does not de-duplicate: a middleware an application | ||
| * already attaches itself via {@code builder.middleware(...)} would be registered twice. Set | ||
| * this to {@code false} to opt out and wire middleware yourself. | ||
| */ | ||
| private boolean autoAssembleMiddleware = true; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the hand-written metadata entry is gone, this field javadoc is now the only source of the hint a user sees when the default bites them. Two facts are worth having in that one paragraph, because they are what a surprised applicant asks: that auto-assembly does not de-duplicate, so middleware already attached by the application or by another customizer is registered a second time; and that the opt-out is this single flag rather than removing the beans. Same note applies to |
||
|
|
||
| /** | ||
| * Whether every {@code Hook} bean is auto-attached to the agent builder. | ||
| * | ||
| * <p>Default {@code false}. {@code Hook} is deprecated for removal, so auto-attach is opt-in; | ||
| * when enabled it does not de-duplicate against hooks the application attaches itself. Set | ||
| * this to {@code true} to opt in. | ||
| */ | ||
| private boolean autoAssembleHooks = false; | ||
|
|
||
| public boolean isEnabled() { | ||
| return enabled; | ||
| } | ||
|
|
@@ -82,4 +100,20 @@ public int getMaxIters() { | |
| public void setMaxIters(int maxIters) { | ||
| this.maxIters = maxIters; | ||
| } | ||
|
|
||
| public boolean isAutoAssembleMiddleware() { | ||
| return autoAssembleMiddleware; | ||
| } | ||
|
|
||
| public void setAutoAssembleMiddleware(boolean autoAssembleMiddleware) { | ||
| this.autoAssembleMiddleware = autoAssembleMiddleware; | ||
| } | ||
|
|
||
| public boolean isAutoAssembleHooks() { | ||
| return autoAssembleHooks; | ||
| } | ||
|
|
||
| public void setAutoAssembleHooks(boolean autoAssembleHooks) { | ||
| this.autoAssembleHooks = autoAssembleHooks; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| package io.agentscope.spring.boot.properties; | ||
|
|
||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||
| import org.springframework.boot.context.properties.NestedConfigurationProperty; | ||
|
|
||
| /** | ||
| * Root configuration properties for AgentScope Spring Boot starter. | ||
|
|
@@ -26,13 +27,18 @@ | |
| * <li>{@link AgentProperties} under {@code agentscope.agent}</li> | ||
| * <li>{@link ModelProperties} under {@code agentscope.model}</li> | ||
| * </ul> | ||
| * | ||
| * <p>{@link NestedConfigurationProperty} makes the configuration metadata processor expand | ||
| * these nested groups into {@code spring-configuration-metadata.json}. Measured on this starter, | ||
| * omitting the annotation leaves the generated {@code properties} array empty, so it is required | ||
| * here for {@code agentscope.agent.*} / {@code agentscope.model.*} entries to appear. | ||
| */ | ||
| @ConfigurationProperties(prefix = "agentscope") | ||
| public class AgentscopeProperties { | ||
|
|
||
| private final AgentProperties agent = new AgentProperties(); | ||
| @NestedConfigurationProperty private final AgentProperties agent = new AgentProperties(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This head deletes |
||
|
|
||
| private final ModelProperties model = new ModelProperties(); | ||
| @NestedConfigurationProperty private final ModelProperties model = new ModelProperties(); | ||
|
|
||
| public AgentProperties getAgent() { | ||
| return agent; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Info] This mirrors the Boot idiom (
WebServerFactoryCustomizer) except that it both extendsConsumer<ReActAgent.Builder>and declarescustomize. Theacceptdefault keeps the two in sync today, but it makes the interface assignable toConsumer, so a plainConsumer<ReActAgent.Builder>lambda can no longer be distinguished from a customizer and the ordering contract silently applies to both. Consider droppingextends Consumerand keepingcustomizeonly — cheaper to do now than after 2.0.4 ships.