diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentBuilderCustomizer.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentBuilderCustomizer.java new file mode 100644 index 0000000000..d0c8aebd08 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentBuilderCustomizer.java @@ -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}. + * + *

Example usage: + * + *

{@code
+ *     @Bean
+ *     public AgentBuilderCustomizer agentBuilderCustomizer() {
+ *         return builder -> builder.middleware(new MyMiddleware());
+ *     }
+ * }
+ * + * @see AgentscopeAutoConfiguration#agentscopeReActAgent + */ +@FunctionalInterface +public interface AgentBuilderCustomizer extends Consumer { + + /** + * 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); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java index b509d42b82..d2eec59eca 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java @@ -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 * } + * + *

In addition to the core beans, this configuration provides the following + * conveniences when {@code agentscope.agent.enabled=true}: + * + *

+ * + *

Both conveniences are implemented as {@link AgentBuilderCustomizer} beans ordered with + * {@link Ordered#HIGHEST_PRECEDENCE}, so a user-defined {@code AgentBuilderCustomizer} + * without an explicit {@code @Order} (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, + Toolkit toolkit, + AgentscopeProperties properties, + ObjectProvider 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)); + 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}. + * + *

Ordered at {@link Ordered#HIGHEST_PRECEDENCE}{@code + 10} — before the permission and + * hook customizers, and before any user-defined {@link AgentBuilderCustomizer}. + * + *

Sharing contract: 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. + * + *

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) + @ConditionalOnMissingBean(name = "middlewareAutoCustomizer") + public AgentBuilderCustomizer middlewareAutoCustomizer( + ObjectProvider middlewares) { + return builder -> { + List 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. + * + *

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. + * + *

Ordered at {@link Ordered#HIGHEST_PRECEDENCE}{@code + 20} — after middleware assembly + * but still before the hook customizer and any user-defined {@link AgentBuilderCustomizer}. + * + *

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( + ObjectProvider permissionContext) { + return builder -> { + List 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"); + } + }; } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/HookAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/HookAutoConfiguration.java new file mode 100644 index 0000000000..807f4386e2 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/HookAutoConfiguration.java @@ -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. + * + *

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. + * + *

Opt-in: 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. + * + *

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 + * {@link org.springframework.core.annotation.Order @Order}. + * + *

Disabled by default; enable with {@code agentscope.agent.auto-assemble-hooks=true}. + * + *

Backs off when a bean named {@code hookAutoCustomizer} already exists + * ({@code @ConditionalOnMissingBean(name = ...)}), so supplying one disables or replaces this + * auto-attach. + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 30) + @ConditionalOnProperty( + prefix = "agentscope.agent", + name = "auto-assemble-hooks", + havingValue = "true") + @ConditionalOnMissingBean(name = "hookAutoCustomizer") + public AgentBuilderCustomizer hookAutoCustomizer(ObjectProvider hooks) { + return builder -> { + List beans = hooks.orderedStream().toList(); + if (!beans.isEmpty()) { + if (logger.isDebugEnabled()) { + logger.debug( + "Auto-assembled {} Hook bean class(es): {}", + beans.size(), + beans.stream().map(b -> b.getClass().getSimpleName()).toList()); + } + beans.forEach(builder::hook); + } + }; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentProperties.java index ef1bd973a9..51d36f72c3 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentProperties.java @@ -51,6 +51,24 @@ public class AgentProperties { */ private int maxIters = 10; + /** + * Whether every {@code MiddlewareBase} bean is auto-injected into the agent builder. + * + *

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; + + /** + * Whether every {@code Hook} bean is auto-attached to the agent builder. + * + *

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; + } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java index 24fd7158c2..7b8d30c5e8 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java @@ -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 @@ *

  • {@link AgentProperties} under {@code agentscope.agent}
  • *
  • {@link ModelProperties} under {@code agentscope.model}
  • * + * + *

    {@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(); - private final ModelProperties model = new ModelProperties(); + @NestedConfigurationProperty private final ModelProperties model = new ModelProperties(); public AgentProperties getAgent() { return agent; diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 69fe4fdd74..ab8a31fc4e 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -14,3 +14,4 @@ # limitations under the License. # io.agentscope.spring.boot.AgentscopeAutoConfiguration +io.agentscope.spring.boot.HookAutoConfiguration diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java index cb58146b48..b908b68ea6 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java @@ -21,10 +21,13 @@ import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.memory.Memory; import io.agentscope.core.message.Msg; +import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.permission.PermissionContextState; +import io.agentscope.core.permission.PermissionMode; import io.agentscope.core.tool.Toolkit; import java.util.List; import org.junit.jupiter.api.Test; @@ -100,6 +103,139 @@ void shouldBackOffWhenUserDefinesMemoryToolkitAndAgentBeans() { }); } + // ------------------------------------------------------------------ + // AgentBuilderCustomizer tests + // ------------------------------------------------------------------ + + @Test + void shouldApplyAgentBuilderCustomizer() { + contextRunner + .withUserConfiguration( + CustomModelConfiguration.class, MaxItersCustomizerConfiguration.class) + .run( + context -> { + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getMaxIters()).isEqualTo(99); + }); + } + + // ------------------------------------------------------------------ + // Middleware auto-assembly tests + // ------------------------------------------------------------------ + + @Test + void shouldAutoInjectMiddlewareBeans() { + contextRunner + .withUserConfiguration( + CustomModelConfiguration.class, MiddlewareConfiguration.class) + .run( + context -> { + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getMiddlewares()) + .anyMatch(mw -> mw instanceof TestMiddleware); + }); + } + + @Test + void shouldSkipMiddlewareAutoAssemblyWhenDisabled() { + contextRunner + .withUserConfiguration( + CustomModelConfiguration.class, MiddlewareConfiguration.class) + .withPropertyValues("agentscope.agent.auto-assemble-middleware=false") + .run( + context -> { + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getMiddlewares()) + .noneMatch(mw -> mw instanceof TestMiddleware); + }); + } + + @Test + void userCustomizerShouldRunAfterAutoMiddlewareCustomizer() { + contextRunner + .withUserConfiguration( + CustomModelConfiguration.class, + MiddlewareConfiguration.class, + UserMiddlewareCustomizerConfiguration.class) + .run( + context -> { + List middlewares = + context.getBean(ReActAgent.class).getMiddlewares(); + int autoIndex = indexOf(middlewares, TestMiddleware.class); + int userIndex = indexOf(middlewares, UserMiddleware.class); + assertThat(autoIndex).isGreaterThanOrEqualTo(0); + assertThat(userIndex).isGreaterThan(autoIndex); + }); + } + + // ------------------------------------------------------------------ + // PermissionContextState auto-injection tests + // ------------------------------------------------------------------ + + @Test + void shouldAutoInjectPermissionContextStateWhenBeanExists() { + contextRunner + .withUserConfiguration( + CustomModelConfiguration.class, PermissionConfiguration.class) + .run( + context -> { + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getAgentState().getPermissionContext().getMode()) + .isEqualTo(PermissionMode.ACCEPT_EDITS); + }); + } + + @Test + void shouldNotRequirePermissionContextStateBean() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class) + .run( + context -> { + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getAgentState().getPermissionContext().getMode()) + .isEqualTo(PermissionMode.DEFAULT); + }); + } + + @Test + void shouldNotFailWhenMultiplePermissionContextStateBeans() { + contextRunner + .withUserConfiguration( + CustomModelConfiguration.class, + PermissionConfiguration.class, + SecondPermissionConfiguration.class) + .run( + context -> { + // Two PermissionContextState beans make the context ambiguous, so + // the customizer refuses to inject: no exception, no auto-injection + // (the mode stays at the default) and a warning is logged. + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getAgentState().getPermissionContext().getMode()) + .isEqualTo(PermissionMode.DEFAULT); + }); + } + + @Test + void userCustomizerShouldOverrideAutoInjectedPermissionContext() { + contextRunner + .withUserConfiguration( + CustomModelConfiguration.class, + PermissionConfiguration.class, + UserPermissionOverrideConfiguration.class) + .run( + context -> { + // Auto customizer injects ACCEPT_EDITS first; the user customizer + // runs afterwards and overwrites it with BYPASS. + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getAgentState().getPermissionContext().getMode()) + .isEqualTo(PermissionMode.BYPASS); + }); + } + + // ------------------------------------------------------------------ + // Test configurations + // ------------------------------------------------------------------ + @Configuration(proxyBeanMethods = false) static class CustomModelConfiguration { @@ -133,6 +269,83 @@ ReActAgent customAgent(Model model, Toolkit toolkit) { } } + @Configuration(proxyBeanMethods = false) + static class MaxItersCustomizerConfiguration { + + @Bean + AgentBuilderCustomizer maxItersCustomizer() { + return builder -> builder.maxIters(99); + } + } + + @Configuration(proxyBeanMethods = false) + static class MiddlewareConfiguration { + + @Bean + TestMiddleware testMiddleware() { + return new TestMiddleware(); + } + } + + @Configuration(proxyBeanMethods = false) + static class UserMiddlewareCustomizerConfiguration { + + @Bean + AgentBuilderCustomizer userMiddlewareCustomizer() { + return builder -> builder.middleware(new UserMiddleware()); + } + } + + @Configuration(proxyBeanMethods = false) + static class PermissionConfiguration { + + @Bean + PermissionContextState permissionContextState() { + return PermissionContextState.builder().mode(PermissionMode.ACCEPT_EDITS).build(); + } + } + + @Configuration(proxyBeanMethods = false) + static class SecondPermissionConfiguration { + + @Bean + PermissionContextState secondPermissionContextState() { + return PermissionContextState.builder().mode(PermissionMode.EXPLORE).build(); + } + } + + @Configuration(proxyBeanMethods = false) + static class UserPermissionOverrideConfiguration { + + @Bean + AgentBuilderCustomizer userPermissionOverride() { + return builder -> + builder.permissionContext( + PermissionContextState.builder().mode(PermissionMode.BYPASS).build()); + } + } + + // ------------------------------------------------------------------ + // Test beans + // ------------------------------------------------------------------ + + static class TestMiddleware implements MiddlewareBase { + // All hook methods have default implementations; no override needed. + } + + static class UserMiddleware implements MiddlewareBase { + // All hook methods have default implementations; no override needed. + } + + private static int indexOf(List list, Class type) { + for (int i = 0; i < list.size(); i++) { + if (type.isInstance(list.get(i))) { + return i; + } + } + return -1; + } + private static final class TestModel implements Model { @Override public Flux stream( diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/HookAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/HookAutoConfigurationTest.java new file mode 100644 index 0000000000..f98b8ba46c --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/HookAutoConfigurationTest.java @@ -0,0 +1,113 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Tests for {@link HookAutoConfiguration}. + * + *

    Isolated because {@link Hook} and {@link io.agentscope.core.hook.HookEvent} + * are {@link Deprecated} for removal. + */ +@SuppressWarnings("deprecation") +class HookAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + AgentscopeAutoConfiguration.class, HookAutoConfiguration.class)) + .withPropertyValues("agentscope.agent.enabled=true"); + + @Test + void shouldNotAutoInjectHooksByDefault() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class, HookConfiguration.class) + .run( + context -> { + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getHooks()).noneMatch(h -> h instanceof TestHook); + }); + } + + @Test + void shouldAutoInjectHookBeansWhenEnabled() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class, HookConfiguration.class) + .withPropertyValues("agentscope.agent.auto-assemble-hooks=true") + .run( + context -> { + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getHooks()).anyMatch(h -> h instanceof TestHook); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomModelConfiguration { + + @Bean + Model customModel() { + return new TestModel(); + } + } + + @Configuration(proxyBeanMethods = false) + static class HookConfiguration { + + @Bean + TestHook testHook() { + return new TestHook(); + } + } + + static class TestHook implements Hook { + @Override + public Mono onEvent(T event) { + return Mono.just(event); + } + } + + private static final class TestModel implements Model { + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.empty(); + } + + @Override + public String getModelName() { + return "custom-model"; + } + } +} diff --git a/docs/v2/en/docs/building-blocks/middleware.md b/docs/v2/en/docs/building-blocks/middleware.md index c00545064b..44944970b4 100644 --- a/docs/v2/en/docs/building-blocks/middleware.md +++ b/docs/v2/en/docs/building-blocks/middleware.md @@ -67,6 +67,22 @@ ReActAgent agent = `middleware(...)` (singular) adds one; `middlewares(...)` accepts `List`. Hooks not implemented by a middleware are skipped at zero cost. + + +**Spring Boot auto-assembly.** When using `agentscope-spring-boot-starter`, declare middleware as beans and they will be auto-injected into the agent builder, ordered by `@Order` — no manual `builder.middleware(...)` needed: + +```java +@Bean +@Order(100) +public MiddlewareBase timingMiddleware() { return new TimingMiddleware(); } +``` + +This works via `AgentBuilderCustomizer`, an SPI that mirrors `ChatModelBuilderCustomizer`. Similarly, a unique `PermissionContextState` bean (if present) is auto-applied to the builder; when two or more are present, none is injected and a warning is logged. User-defined `AgentBuilderCustomizer` beans run after the auto-configured ones, so they can always override. + +Auto-assembly is additive: if you also call `builder.middleware(...)` yourself, the same middleware runs twice. Either declare the middleware only as a `@Bean`, or set `agentscope.agent.auto-assemble-middleware=false` and wire it manually. Hooks are opt-in (the API is deprecated): enable auto-attach with `agentscope.agent.auto-assemble-hooks=true`. + + + ## Built-in middlewares ### OtelTracingMiddleware diff --git a/docs/v2/zh/docs/building-blocks/middleware.md b/docs/v2/zh/docs/building-blocks/middleware.md index e92c52ba9e..d82b242286 100644 --- a/docs/v2/zh/docs/building-blocks/middleware.md +++ b/docs/v2/zh/docs/building-blocks/middleware.md @@ -67,6 +67,22 @@ ReActAgent agent = `middleware(...)`(单数)也可单独添加一个;`middlewares(...)` 接受 `List`,未实现的位置自动跳过,不产生任何调用开销。 + + +**Spring Boot 自动装配。** 使用 `agentscope-spring-boot-starter` 时,声明 `@Bean MiddlewareBase` 即自动注入 agent builder,按 `@Order` 排序 —— 无需手动 `builder.middleware(...)`: + +```java +@Bean +@Order(100) +public MiddlewareBase timingMiddleware() { return new TimingMiddleware(); } +``` + +此机制通过 `AgentBuilderCustomizer` 实现,与 `ChatModelBuilderCustomizer` 模式一致。同理,唯一的 `PermissionContextState` bean(如有)自动应用到 builder;当存在两个或以上时,不注入任何 context 并打印告警。用户自定义的 `AgentBuilderCustomizer` 在自动装配之后执行,因此总能覆盖自动配置。 + +自动装配是**追加式**的:如果你自己又调用了 `builder.middleware(...)`,同一个 middleware 会执行两次。要么只以 `@Bean` 声明一次,要么设置 `agentscope.agent.auto-assemble-middleware=false` 并手动装配。Hook 为 opt-in(该 API 已废弃):用 `agentscope.agent.auto-assemble-hooks=true` 开启自动装配。 + + + ## 内置 Middleware ### OtelTracingMiddleware