diff --git a/core/src/main/java/com/google/adk/apps/App.java b/core/src/main/java/com/google/adk/apps/App.java index db1dc9b7a..385ae35b9 100644 --- a/core/src/main/java/com/google/adk/apps/App.java +++ b/core/src/main/java/com/google/adk/apps/App.java @@ -16,8 +16,6 @@ package com.google.adk.apps; -import static com.google.common.base.Preconditions.checkNotNull; - import com.google.adk.agents.BaseAgent; import com.google.adk.agents.ContextCacheConfig; import com.google.adk.agents.NodeViewAgent; @@ -105,10 +103,30 @@ public ContextCacheConfig contextCacheConfig() { return resumabilityConfig; } + /** + * Returns a builder holding this app's settings. For an app that runs a node, the builder holds + * {@link #rootNode()} rather than the agent that stands in for it. To switch between a root agent + * and a root node, also clear the current one with {@code null}. + */ + public Builder toBuilder() { + Builder builder = new Builder(); + builder.name = name; + if (rootNode != null) { + builder.rootNode = rootNode; + } else { + builder.rootAgent = rootAgent; + } + builder.plugins = plugins; + builder.eventsCompactionConfig = eventsCompactionConfig; + builder.contextCacheConfig = contextCacheConfig; + builder.resumabilityConfig = resumabilityConfig; + return builder; + } + /** Builder for {@link App}. */ public static class Builder { private String name; - private BaseAgent rootAgent; + private @Nullable BaseAgent rootAgent; private @Nullable Node rootNode; private List plugins = ImmutableList.of(); @Nullable private EventsCompactionConfig eventsCompactionConfig; @@ -121,20 +139,21 @@ public Builder name(String name) { return this; } + /** Sets the root agent, or clears it when {@code null}. */ @CanIgnoreReturnValue - public Builder rootAgent(BaseAgent rootAgent) { + public Builder rootAgent(@Nullable BaseAgent rootAgent) { this.rootAgent = rootAgent; return this; } /** - * Sets the workflow node the app runs in place of a root agent. If the given node is an agent, - * it becomes the root agent. + * Sets the workflow node the app runs in place of a root agent, or clears it when {@code null}. + * If the given node is an agent, it becomes the root agent. */ @CanIgnoreReturnValue @Experimental - public Builder rootNode(Node rootNode) { - this.rootNode = checkNotNull(rootNode); + public Builder rootNode(@Nullable Node rootNode) { + this.rootNode = rootNode; return this; } @@ -151,20 +170,20 @@ public Builder plugins(Plugin... plugins) { } @CanIgnoreReturnValue - public Builder eventsCompactionConfig(EventsCompactionConfig eventsCompactionConfig) { + public Builder eventsCompactionConfig(@Nullable EventsCompactionConfig eventsCompactionConfig) { this.eventsCompactionConfig = eventsCompactionConfig; return this; } @CanIgnoreReturnValue - public Builder contextCacheConfig(ContextCacheConfig contextCacheConfig) { + public Builder contextCacheConfig(@Nullable ContextCacheConfig contextCacheConfig) { this.contextCacheConfig = contextCacheConfig; return this; } @CanIgnoreReturnValue @Experimental - public Builder resumabilityConfig(ResumabilityConfig resumabilityConfig) { + public Builder resumabilityConfig(@Nullable ResumabilityConfig resumabilityConfig) { this.resumabilityConfig = resumabilityConfig; return this; } @@ -200,7 +219,8 @@ private BaseAgent resolveRootAgent() { return rootAgent; } if (rootAgent != null) { - throw new IllegalStateException("Set a root agent or a root node, not both."); + throw new IllegalStateException( + "Set a root agent or a root node, not both; clear the other with null."); } return rootNode instanceof BaseAgent agent ? agent : new NodeViewAgent(rootNode); } diff --git a/core/src/test/java/com/google/adk/apps/AppTest.java b/core/src/test/java/com/google/adk/apps/AppTest.java index 7202e2233..c76d19b81 100644 --- a/core/src/test/java/com/google/adk/apps/AppTest.java +++ b/core/src/test/java/com/google/adk/apps/AppTest.java @@ -20,7 +20,11 @@ import static org.junit.Assert.assertThrows; import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.ContextCacheConfig; import com.google.adk.agents.NodeViewAgent; +import com.google.adk.plugins.BasePlugin; +import com.google.adk.plugins.Plugin; +import com.google.adk.summarizer.EventsCompactionConfig; import com.google.adk.testing.TestNode; import com.google.adk.testing.TestUtils; import com.google.adk.workflow.Node; @@ -95,4 +99,102 @@ public void build_withANodeViewAgentAsTheRootNode_throwsIllegalStateException() assertThrows(IllegalStateException.class, builder::build); } + + @Test + public void toBuilder_unchanged_buildsAnAppWithTheSameSettings() { + BaseAgent agent = TestUtils.createSubAgent("worker"); + Plugin plugin = new BasePlugin("plugin") {}; + EventsCompactionConfig compaction = new EventsCompactionConfig(3, 1); + ContextCacheConfig cache = new ContextCacheConfig(); + ResumabilityConfig resumability = ResumabilityConfig.builder().resumable(true).build(); + App app = + App.builder() + .name("agent_app") + .rootAgent(agent) + .plugins(plugin) + .eventsCompactionConfig(compaction) + .contextCacheConfig(cache) + .resumabilityConfig(resumability) + .build(); + + App copy = app.toBuilder().build(); + + assertThat(copy.name()).isEqualTo("agent_app"); + assertThat(copy.rootAgent()).isSameInstanceAs(agent); + assertThat(copy.rootNode()).isEmpty(); + assertThat(copy.plugins()).containsExactly(plugin); + assertThat(copy.eventsCompactionConfig()).isSameInstanceAs(compaction); + assertThat(copy.contextCacheConfig()).isSameInstanceAs(cache); + assertThat(copy.resumabilityConfig()).isSameInstanceAs(resumability); + } + + @Test + public void toBuilder_ofANodeApp_keepsTheRootNode() { + App app = App.builder().name("node_app").rootNode(NODE).build(); + + App copy = app.toBuilder().build(); + + assertThat(copy.rootNode()).hasValue(NODE); + assertThat(((NodeViewAgent) copy.rootAgent()).node()).isSameInstanceAs(NODE); + } + + @Test + public void toBuilder_changed_buildsAnAppWithTheChanges() { + Plugin plugin = new BasePlugin("plugin") {}; + App app = App.builder().name("node_app").rootNode(NODE).build(); + + App copy = app.toBuilder().name("renamed").plugins(plugin).build(); + + assertThat(copy.name()).isEqualTo("renamed"); + assertThat(copy.plugins()).containsExactly(plugin); + assertThat(copy.rootNode()).hasValue(NODE); + } + + @Test + public void toBuilder_ofANodeAppClearingTheNodeAndSettingAnAgent_runsTheAgent() { + BaseAgent agent = TestUtils.createSubAgent("worker"); + App app = App.builder().name("node_app").rootNode(NODE).build(); + + App copy = app.toBuilder().rootNode(null).rootAgent(agent).build(); + + assertThat(copy.rootAgent()).isSameInstanceAs(agent); + assertThat(copy.rootNode()).isEmpty(); + } + + @Test + public void toBuilder_ofAnAgentAppClearingTheAgentAndSettingANode_runsTheNode() { + App app = App.builder().name("agent_app").rootAgent(TestUtils.createSubAgent("worker")).build(); + + App copy = app.toBuilder().rootAgent(null).rootNode(NODE).build(); + + assertThat(copy.rootNode()).hasValue(NODE); + assertThat(((NodeViewAgent) copy.rootAgent()).node()).isSameInstanceAs(NODE); + } + + @Test + public void toBuilder_ofANodeAppSettingAnotherNode_standsInForTheNewNode() { + Node other = TestNode.of("other"); + App app = App.builder().name("node_app").rootNode(NODE).build(); + + App copy = app.toBuilder().rootNode(other).build(); + + assertThat(copy.rootNode()).hasValue(other); + assertThat(((NodeViewAgent) copy.rootAgent()).node()).isSameInstanceAs(other); + } + + @Test + public void toBuilder_ofANodeAppClearingTheNode_throwsIllegalStateException() { + App app = App.builder().name("node_app").rootNode(NODE).build(); + App.Builder builder = app.toBuilder().rootNode(null); + + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + public void toBuilder_ofANodeAppSettingAnAgent_throwsIllegalStateException() { + App app = App.builder().name("node_app").rootNode(NODE).build(); + App.Builder builder = app.toBuilder().rootAgent(TestUtils.createSubAgent("worker")); + + assertThrows(IllegalStateException.class, builder::build); + } } diff --git a/dev/src/main/java/com/google/adk/web/AdkWebServer.java b/dev/src/main/java/com/google/adk/web/AdkWebServer.java index ac1879a10..c69734d3f 100644 --- a/dev/src/main/java/com/google/adk/web/AdkWebServer.java +++ b/dev/src/main/java/com/google/adk/web/AdkWebServer.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.JsonBaseModel; import com.google.adk.agents.BaseAgent; +import com.google.adk.apps.App; import com.google.adk.artifacts.BaseArtifactService; import com.google.adk.artifacts.InMemoryArtifactService; import com.google.adk.memory.BaseMemoryService; @@ -27,6 +28,7 @@ import com.google.adk.sessions.InMemorySessionService; import com.google.adk.web.config.BackendUrl; import com.google.adk.web.config.DevUiAssets; +import com.google.common.collect.ObjectArrays; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -172,6 +174,20 @@ public static void main(String[] args) { // TODO: return a Closeable that stops the server and resets the static state. public static void start(BaseAgent... agents) { + start("agentLoader", new AgentStaticLoader(agents)); + } + + /** + * Starts the server with {@code app} and {@code moreApps}, each served under its name. An app can + * run an agent or a workflow. + * + * @throws IllegalArgumentException if two apps share a name + */ + public static void start(App app, App... moreApps) { + start("appLoader", new InMemoryAppLoader(ObjectArrays.concat(app, moreApps))); + } + + private static void start(String loaderBeanName, Object loader) { // Disable CompiledAgentLoader by setting property to prevent its creation System.setProperty("adk.agents.loader", "static"); // Increase the default websocket buffer size to 10MB to accommodate live API messages. @@ -184,10 +200,10 @@ public static void start(BaseAgent... agents) { new ApplicationContextInitializer() { @Override public void initialize(ConfigurableApplicationContext context) { - // Register the AgentStaticLoader bean before context refresh + // Register the loader bean before context refresh DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); - beanFactory.registerSingleton("agentLoader", new AgentStaticLoader(agents)); + beanFactory.registerSingleton(loaderBeanName, loader); } }); diff --git a/dev/src/main/java/com/google/adk/web/AppLoader.java b/dev/src/main/java/com/google/adk/web/AppLoader.java new file mode 100644 index 000000000..2f11510d8 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/AppLoader.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 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.web; + +import com.google.adk.apps.App; +import com.google.common.collect.ImmutableList; +import java.util.Optional; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Loads what the ADK Web Server serves: apps, agents and workflows, each as an {@link App} keyed by + * its app name. An agent is served as an app whose root agent it is, and a workflow as an app whose + * root node it is. + * + *

When the server has an {@code AppLoader}, it uses it in place of the default {@link + * CompiledAgentLoader}, and fails at startup if another {@link AgentLoader} is configured too. + */ +@ThreadSafe +public interface AppLoader { + + /** Returns the names of the available apps, each a valid app name; empty if there are none. */ + ImmutableList listApps(); + + /** + * Returns the app served as {@code appName}, or empty if there is none. + * + * @throws RuntimeException if the app exists but fails to load + */ + Optional loadApp(String appName); +} diff --git a/dev/src/main/java/com/google/adk/web/InMemoryAppLoader.java b/dev/src/main/java/com/google/adk/web/InMemoryAppLoader.java new file mode 100644 index 000000000..f203ff0a9 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/InMemoryAppLoader.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 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.web; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.adk.apps.App; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSortedMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** An {@link AppLoader} serving a fixed set of apps, each under its {@link App#name()}. */ +public final class InMemoryAppLoader implements AppLoader { + + private final ImmutableSortedMap apps; + + /** + * Serves {@code apps}. + * + * @throws IllegalArgumentException if two apps share a name + */ + public InMemoryAppLoader(App... apps) { + Map byName = new HashMap<>(); + for (App app : apps) { + checkArgument(byName.putIfAbsent(app.name(), app) == null, "App names must be unique."); + } + this.apps = ImmutableSortedMap.copyOf(byName); + } + + @Override + public ImmutableList listApps() { + return apps.keySet().asList(); + } + + @Override + public Optional loadApp(String appName) { + return Optional.ofNullable(apps.get(appName)); + } +} diff --git a/dev/src/main/java/com/google/adk/web/controller/AgentController.java b/dev/src/main/java/com/google/adk/web/controller/AgentController.java index 08e93cd1d..87ed49a3a 100644 --- a/dev/src/main/java/com/google/adk/web/controller/AgentController.java +++ b/dev/src/main/java/com/google/adk/web/controller/AgentController.java @@ -18,7 +18,11 @@ import static java.util.stream.Collectors.toList; +import com.google.adk.artifacts.InMemoryArtifactService; +import com.google.adk.memory.InMemoryMemoryService; +import com.google.adk.sessions.InMemorySessionService; import com.google.adk.web.AgentLoader; +import com.google.adk.web.service.RunnerService; import com.google.common.collect.ImmutableList; import java.util.List; import org.slf4j.Logger; @@ -33,34 +37,52 @@ public class AgentController { private static final Logger log = LoggerFactory.getLogger(AgentController.class); - private final AgentLoader agentProvider; + private final RunnerService runnerService; /** * Constructs the AgentController. * - * @param agentProvider The provider for loading agents. + * @param runnerService The service serving the configured apps. */ @Autowired - public AgentController(AgentLoader agentProvider) { - this.agentProvider = agentProvider; - ImmutableList agentNames = this.agentProvider.listAgents(); + public AgentController(RunnerService runnerService) { + this.runnerService = runnerService; + ImmutableList agentNames = runnerService.listApps(); log.info( "AgentController initialized with {} dynamic agents: {}", agentNames.size(), agentNames); if (agentNames.isEmpty()) { log.warn( - "Agent registry is empty. Check 'adk.agents.source-dir' property and compilation" - + " logs."); + "No apps are served. Check the configured loader, such as the 'adk.agents.source-dir'" + + " property and compilation logs for compiled agents."); } } /** - * Lists available applications. Currently returns only the configured root agent's name. + * Constructs an AgentController that serves only the agents of {@code agentProvider}. + * + * @param agentProvider The provider for loading agents. + * @deprecated Use {@link #AgentController(RunnerService)} instead. + */ + @Deprecated + public AgentController(AgentLoader agentProvider) { + // Listing apps reads only the loader, so these runner services are never used. + this( + new RunnerService( + agentProvider, + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of())); + } + + /** + * Lists the available applications. * - * @return A list containing the root agent's name. + * @return The names of the served apps, sorted. */ @GetMapping("/list-apps") public List listApps() { - ImmutableList agentNames = agentProvider.listAgents(); + ImmutableList agentNames = runnerService.listApps(); log.info("Listing apps from dynamic registry. Found: {}", agentNames); return agentNames.stream().sorted().collect(toList()); } diff --git a/dev/src/main/java/com/google/adk/web/controller/GraphController.java b/dev/src/main/java/com/google/adk/web/controller/GraphController.java index df2af39af..4f115f72a 100644 --- a/dev/src/main/java/com/google/adk/web/controller/GraphController.java +++ b/dev/src/main/java/com/google/adk/web/controller/GraphController.java @@ -17,12 +17,15 @@ package com.google.adk.web.controller; import com.google.adk.agents.BaseAgent; +import com.google.adk.artifacts.InMemoryArtifactService; import com.google.adk.events.Event; +import com.google.adk.memory.InMemoryMemoryService; import com.google.adk.sessions.BaseSessionService; import com.google.adk.sessions.Session; import com.google.adk.web.AgentGraphGenerator; import com.google.adk.web.AgentLoader; import com.google.adk.web.dto.GraphResponse; +import com.google.adk.web.service.RunnerService; import com.google.common.collect.ImmutableList; import com.google.genai.types.FunctionCall; import com.google.genai.types.FunctionResponse; @@ -48,12 +51,32 @@ public class GraphController { private static final Logger log = LoggerFactory.getLogger(GraphController.class); private final BaseSessionService sessionService; - private final AgentLoader agentProvider; + private final RunnerService runnerService; @Autowired - public GraphController(BaseSessionService sessionService, AgentLoader agentProvider) { + public GraphController(BaseSessionService sessionService, RunnerService runnerService) { this.sessionService = sessionService; - this.agentProvider = agentProvider; + this.runnerService = runnerService; + } + + /** + * Constructs a GraphController that draws only the agents of {@code agentProvider}. + * + * @param sessionService The service holding the sessions whose events are drawn. + * @param agentProvider The provider for loading agents. + * @deprecated Use {@link #GraphController(BaseSessionService, RunnerService)} instead. + */ + @Deprecated + public GraphController(BaseSessionService sessionService, AgentLoader agentProvider) { + // Loading an app's root reads only the loader, so these runner services are never used. + this( + sessionService, + new RunnerService( + agentProvider, + new InMemoryArtifactService(), + sessionService, + new InMemoryMemoryService(), + ImmutableList.of())); } /** @@ -124,18 +147,25 @@ public ResponseEntity getEventGraph( sessionId, eventId); - BaseAgent currentAppAgent; + Optional rootAgent; try { - currentAppAgent = agentProvider.loadAgent(appName); - } catch (java.util.NoSuchElementException e) { - log.warn("Agent app '{}' not found for graph generation.", appName); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new GraphResponse("Agent app not found: " + appName)); + // This route draws agent trees only, so an app that runs a workflow node has no graph here. + rootAgent = + runnerService + .loadRoot(appName) + .filter(BaseAgent.class::isInstance) + .map(BaseAgent.class::cast); } catch (IllegalStateException e) { - log.warn("Agent app '{}' failed to load for graph generation: {}", appName, e.getMessage()); + log.warn("Agent app '{}' failed to load for graph generation.", appName, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new GraphResponse("Agent app failed to load: " + appName)); } + if (rootAgent.isEmpty()) { + log.warn("Agent app '{}' not found for graph generation.", appName); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new GraphResponse("Agent app not found: " + appName)); + } + BaseAgent currentAppAgent = rootAgent.get(); Session session = findSessionOrThrow(appName, userId, sessionId); Event event = diff --git a/dev/src/main/java/com/google/adk/web/service/RunnerService.java b/dev/src/main/java/com/google/adk/web/service/RunnerService.java index 480c68472..38c270b09 100644 --- a/dev/src/main/java/com/google/adk/web/service/RunnerService.java +++ b/dev/src/main/java/com/google/adk/web/service/RunnerService.java @@ -16,44 +16,105 @@ package com.google.adk.web.service; -import com.google.adk.agents.BaseAgent; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.annotations.Experimental; import com.google.adk.artifacts.BaseArtifactService; import com.google.adk.memory.BaseMemoryService; import com.google.adk.plugins.BasePlugin; import com.google.adk.runner.Runner; import com.google.adk.sessions.BaseSessionService; import com.google.adk.web.AgentLoader; +import com.google.adk.web.AppLoader; +import com.google.adk.web.CompiledAgentLoader; +import com.google.adk.workflow.Node; import com.google.common.collect.ImmutableList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; -/** Service for creating and caching Runner instances. */ +/** Serves the configured apps, and creates and caches a Runner for each. */ @Component public class RunnerService { private static final Logger log = LoggerFactory.getLogger(RunnerService.class); - private final AgentLoader agentProvider; + private final ServedApps servedApps; private final BaseArtifactService artifactService; private final BaseSessionService sessionService; private final BaseMemoryService memoryService; private final List extraPlugins; private final Map runnerCache = new ConcurrentHashMap<>(); + /** + * Serves the apps of the configured {@link AppLoader}, or the agents of the configured {@link + * AgentLoader} if there is no {@code AppLoader}. + * + * @throws IllegalStateException if neither loader is configured, or if both are and the agent + * loader is not the default {@link CompiledAgentLoader} + */ + @Autowired + RunnerService( + ObjectProvider appLoader, + ObjectProvider agentLoader, + BaseArtifactService artifactService, + BaseSessionService sessionService, + BaseMemoryService memoryService, + @Autowired(required = false) @Qualifier("extraPlugins") List extraPlugins) { + this( + selectServedApps(appLoader.getIfAvailable(), agentLoader), + artifactService, + sessionService, + memoryService, + extraPlugins); + } + + /** Serves the apps of {@code appLoader}. */ + public RunnerService( + AppLoader appLoader, + BaseArtifactService artifactService, + BaseSessionService sessionService, + BaseMemoryService memoryService, + List extraPlugins) { + this( + new ServedApps.AppLoaderApps(checkNotNull(appLoader)), + artifactService, + sessionService, + memoryService, + extraPlugins); + } + + /** Serves the agents of {@code agentProvider}. */ public RunnerService( AgentLoader agentProvider, BaseArtifactService artifactService, BaseSessionService sessionService, BaseMemoryService memoryService, - @Autowired(required = false) @Qualifier("extraPlugins") List extraPlugins) { - this.agentProvider = agentProvider; + List extraPlugins) { + this( + new ServedApps.AgentLoaderApps(checkNotNull(agentProvider)), + artifactService, + sessionService, + memoryService, + extraPlugins); + } + + private RunnerService( + ServedApps servedApps, + BaseArtifactService artifactService, + BaseSessionService sessionService, + BaseMemoryService memoryService, + List extraPlugins) { + this.servedApps = servedApps; this.artifactService = artifactService; this.sessionService = sessionService; this.memoryService = memoryService; @@ -61,42 +122,70 @@ public RunnerService( extraPlugins != null ? ImmutableList.copyOf(extraPlugins) : ImmutableList.of(); } + private static ServedApps selectServedApps( + @Nullable AppLoader appLoader, ObjectProvider agentLoaders) { + if (appLoader == null) { + return ServedApps.select(null, agentLoaders.getIfAvailable()); + } + // The default loader is @Primary, so look past it for one the app loader would hide. + return ServedApps.select( + appLoader, + agentLoaders + .orderedStream() + .filter(loader -> !(loader instanceof CompiledAgentLoader)) + .findFirst() + .orElse(null)); + } + + /** Returns the names of the apps this server serves. */ + public ImmutableList listApps() { + return servedApps.listApps(); + } + /** - * Gets the Runner instance for a given application name. Handles potential agent engine ID - * overrides. + * Returns the root that the app {@code appName} runs: the workflow node it runs, or else its root + * agent. Empty if there is no such app. + * + * @throws IllegalStateException if the app exists but fails to load + */ + @Experimental + public Optional loadRoot(String appName) { + return servedApps.loadRoot(appName); + } + + /** + * Gets the Runner instance for a given application name. An app runs under the requested name, + * with this server's plugins after its own. * * @param appName The application name requested by the user. * @return A configured Runner instance. + * @throws ResponseStatusException with 404 if there is no such app, or 500 if it fails to load */ public Runner getRunner(String appName) { - return runnerCache.computeIfAbsent( - appName, - key -> { - try { - BaseAgent agent = agentProvider.loadAgent(key); - log.info( - "RunnerService: Creating Runner for appName: {}, using agent definition: {}", - appName, - agent.name()); - return Runner.builder() - .agent(agent) - .appName(appName) - .artifactService(this.artifactService) - .sessionService(this.sessionService) - .memoryService(this.memoryService) - .plugins(this.extraPlugins) - .build(); - } catch (java.util.NoSuchElementException e) { - log.error( - "Agent/App named '{}' not found in registry. Available apps: {}", - key, - agentProvider.listAgents()); - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent/App not found: " + key); - } catch (IllegalStateException e) { - log.error("Agent '{}' exists but failed to load: {}", key, e.getMessage()); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Agent failed to load: " + key, e); - } + return runnerCache.computeIfAbsent(appName, this::createRunner); + } + + private Runner createRunner(String appName) { + Optional runner; + try { + runner = + servedApps.createRunner( + appName, + Runner.builder() + .artifactService(artifactService) + .sessionService(sessionService) + .memoryService(memoryService), + extraPlugins); + } catch (RuntimeException e) { + log.error("The requested app failed to load.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "App failed to load.", e); + } + return runner.orElseThrow( + () -> { + log.warn( + "No app is served under the requested name; {} apps are available.", + servedApps.listApps().size()); + return new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found."); }); } diff --git a/dev/src/main/java/com/google/adk/web/service/ServedApps.java b/dev/src/main/java/com/google/adk/web/service/ServedApps.java new file mode 100644 index 000000000..23c7fae0a --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/service/ServedApps.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 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.web.service; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.apps.App; +import com.google.adk.plugins.Plugin; +import com.google.adk.runner.Runner; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AppLoader; +import com.google.adk.workflow.Node; +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** What the server serves, read from whichever loader it was configured with. */ +interface ServedApps { + + /** Returns the names that {@code /list-apps} lists. */ + ImmutableList listApps(); + + /** + * Returns the root that the app {@code appName} runs, or empty if there is no such app. + * + * @throws IllegalStateException if the app exists but fails to load + */ + Optional loadRoot(String appName); + + /** + * Returns a runner for {@code appName}, built on {@code builder} with {@code plugins} after the + * app's own, or empty if there is no such app. + * + * @throws IllegalStateException if the app exists but fails to load + */ + Optional createRunner( + String appName, Runner.Builder builder, List plugins); + + /** + * Serves {@code appLoader}'s apps if there is one, else {@code agentLoader}'s agents. + * + * @throws IllegalStateException if there is neither, or both + */ + static ServedApps select(@Nullable AppLoader appLoader, @Nullable AgentLoader agentLoader) { + if (appLoader != null) { + checkState(agentLoader == null, "Configure an AppLoader or an AgentLoader, not both."); + return new AppLoaderApps(appLoader); + } + checkState(agentLoader != null, "Configure an AppLoader or an AgentLoader."); + return new AgentLoaderApps(agentLoader); + } + + /** Serves an {@link AppLoader}'s apps, each under the requested name. */ + final class AppLoaderApps implements ServedApps { + private final AppLoader loader; + + AppLoaderApps(AppLoader loader) { + this.loader = loader; + } + + @Override + public ImmutableList listApps() { + return loader.listApps(); + } + + @Override + public Optional loadRoot(String appName) { + return load(appName).map(app -> app.rootNode().orElse(app.rootAgent())); + } + + @Override + public Optional createRunner( + String appName, Runner.Builder builder, List plugins) { + return load(appName) + .map( + app -> + builder + .app( + app.toBuilder() + .name(appName) + .plugins( + ImmutableList.builder() + .addAll(app.plugins()) + .addAll(plugins) + .build()) + .build()) + .build()); + } + + private Optional load(String appName) { + try { + return loader.loadApp(appName); + } catch (RuntimeException e) { + throw new IllegalStateException("The app failed to load.", e); + } + } + } + + /** + * Serves an {@link AgentLoader}'s agents as the server did before apps: without an {@link App}. + */ + final class AgentLoaderApps implements ServedApps { + private final AgentLoader loader; + + AgentLoaderApps(AgentLoader loader) { + this.loader = loader; + } + + @Override + public ImmutableList listApps() { + return loader.listAgents(); + } + + @Override + public Optional loadRoot(String appName) { + return load(appName).map(Node.class::cast); + } + + @Override + public Optional createRunner( + String appName, Runner.Builder builder, List plugins) { + return load(appName) + .map(agent -> builder.agent(agent).appName(appName).plugins(plugins).build()); + } + + private Optional load(String appName) { + try { + return Optional.of(loader.loadAgent(appName)); + } catch (NoSuchElementException e) { + return Optional.empty(); + } catch (RuntimeException e) { + throw new IllegalStateException("The app failed to load.", e); + } + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerAppLoaderTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerAppLoaderTest.java new file mode 100644 index 000000000..297150ba4 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerAppLoaderTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 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.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.apps.App; +import com.google.adk.web.testing.StepNode; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** Tests the server with an {@link AppLoader} bean next to the default compiled agent loader. */ +@SpringBootTest +@AutoConfigureMockMvc +class AdkWebServerAppLoaderTest { + + @TestConfiguration + static class Apps { + @Bean + AppLoader appLoader() { + Workflow workflow = + Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, new StepNode("step"))) + .build(); + return new InMemoryAppLoader( + App.builder().name("flow_app").rootNode(workflow).build(), + App.builder() + .name("agent_app") + .rootAgent(LlmAgent.builder().name("assistant").model("gemini-2.5-flash").build()) + .build()); + } + } + + @Autowired private MockMvc mockMvc; + + @Test + void listApps_appLoader_returnsItsAppsSorted() throws Exception { + mockMvc + .perform(get("/list-apps")) + .andExpect(status().isOk()) + .andExpect(content().string("[\"agent_app\",\"flow_app\"]")); + } + + @Test + void run_unknownApp_returnsNotFound() throws Exception { + mockMvc + .perform( + post("/run") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"appName\":\"missing\",\"userId\":\"user\",\"sessionId\":\"session\"," + + "\"newMessage\":{\"role\":\"user\",\"parts\":[{\"text\":\"hi\"}]}}")) + .andExpect(status().isNotFound()); + } + + @Test + void eventGraph_agentApp_findsTheRootAgent() throws Exception { + mockMvc + .perform( + post("/apps/agent_app/users/user/sessions/session") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + + // An unknown event gets an empty graph only once the app's root agent is found. + mockMvc + .perform(get("/apps/agent_app/users/user/sessions/session/events/event/graph")) + .andExpect(status().isOk()); + } + + @Test + void eventGraph_workflowApp_returnsNotFound() throws Exception { + mockMvc + .perform(get("/apps/flow_app/users/user/sessions/session/events/event/graph")) + .andExpect(status().isNotFound()); + } +} diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerAppRunTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerAppRunTest.java new file mode 100644 index 000000000..8bf2c126b --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerAppRunTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 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.web; + +import static com.google.common.truth.Truth.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.google.adk.agents.InvocationContext; +import com.google.adk.apps.App; +import com.google.adk.apps.ResumabilityConfig; +import com.google.adk.plugins.BasePlugin; +import com.google.adk.web.testing.StepNode; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Node; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Content; +import io.reactivex.rxjava3.core.Maybe; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; + +/** Runs apps end to end through {@code /run} and {@code /run_sse}. */ +@SpringBootTest +@AutoConfigureMockMvc +class AdkWebServerAppRunTest { + + private static final String USER_ID = "user"; + + /** The names of the plugins whose before-run callback ran, in order. */ + private static final List beforeRunCalls = new CopyOnWriteArrayList<>(); + + @TestConfiguration + static class Apps { + @Bean + AppLoader appLoader() { + App flowApp = + App.builder() + .name("flow_app") + .rootNode(greetingWorkflow()) + .plugins(new RecordingPlugin("app_plugin")) + .build(); + App resumableApp = + App.builder() + .name("resumable_app") + .rootNode(greetingWorkflow()) + .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build()) + .build(); + App greeter = App.builder().name("greeter").rootNode(greetingWorkflow()).build(); + InMemoryAppLoader apps = new InMemoryAppLoader(flowApp, resumableApp); + // Serves greeter under "alias", not its own name, as a loader keyed by something else may. + return new AppLoader() { + @Override + public ImmutableList listApps() { + return ImmutableList.builder().addAll(apps.listApps()).add("alias").build(); + } + + @Override + public Optional loadApp(String appName) { + return appName.equals("alias") ? Optional.of(greeter) : apps.loadApp(appName); + } + }; + } + + @Bean(name = "extraPlugins") + List extraPlugins() { + return ImmutableList.of(new RecordingPlugin("server_plugin")); + } + } + + @Autowired private MockMvc mockMvc; + + @BeforeEach + void clearCalls() { + beforeRunCalls.clear(); + } + + @Test + void run_workflowApp_runsTheGraph() throws Exception { + String sessionId = createSession("flow_app"); + + mockMvc + .perform(runRequest("/run", "flow_app", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].output", Matchers.hasItems("greet", "done"))) + .andExpect(jsonPath("$[*].nodeInfo.path", Matchers.hasItem("flow@1/done@1"))) + .andExpect(jsonPath("$[*].actions.agentState").isEmpty()); + } + + @Test + void run_appAndServerPlugins_runsTheAppsFirst() throws Exception { + String sessionId = createSession("flow_app"); + + mockMvc.perform(runRequest("/run", "flow_app", sessionId)).andExpect(status().isOk()); + + assertThat(beforeRunCalls).containsExactly("app_plugin", "server_plugin").inOrder(); + } + + @Test + void runSse_workflowApp_streamsGraphEvents() throws Exception { + String sessionId = createSession("flow_app"); + + String stream = runSse("flow_app", sessionId); + + assertThat(stream).contains("\"output\":\"greet\""); + assertThat(stream).contains("\"output\":\"done\""); + } + + @Test + void run_resumableApp_emitsTheWorkflowCheckpoints() throws Exception { + String sessionId = createSession("resumable_app"); + + mockMvc + .perform(runRequest("/run", "resumable_app", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].actions.agentState.nodes").isNotEmpty()) + .andExpect(jsonPath("$[*].output", Matchers.hasItem("done"))); + } + + @Test + void runSse_resumableApp_streamsTheWorkflowCheckpoints() throws Exception { + String sessionId = createSession("resumable_app"); + + String stream = runSse("resumable_app", sessionId); + + assertThat(stream).contains("\"agentState\":{\"nodes\":"); + assertThat(stream).contains("\"output\":\"done\""); + } + + @Test + void run_appServedUnderAnotherName_keepsTheSessionUnderTheRequestedName() throws Exception { + String sessionId = createSession("alias"); + + mockMvc.perform(runRequest("/run", "alias", sessionId)).andExpect(status().isOk()); + + mockMvc + .perform(get("/apps/alias/users/" + USER_ID + "/sessions/" + sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.events[*].output", Matchers.hasItem("done"))); + } + + private String createSession(String appName) throws Exception { + String sessionId = UUID.randomUUID().toString(); + mockMvc + .perform( + post("/apps/" + appName + "/users/" + USER_ID + "/sessions/" + sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + return sessionId; + } + + private String runSse(String appName, String sessionId) throws Exception { + MvcResult result = + mockMvc + .perform(runRequest("/run_sse", appName, sessionId)) + .andExpect(request().asyncStarted()) + .andReturn(); + // A stream that ended in an error would leave the error as the async result. + assertThat(result.getAsyncResult(10_000)).isNull(); + return result.getResponse().getContentAsString(); + } + + private static RequestBuilder runRequest(String path, String appName, String sessionId) { + return post(path) + .contentType(MediaType.APPLICATION_JSON) + .content( + String.format( + """ + {"appName": "%s", "userId": "%s", "sessionId": "%s", + "newMessage": {"role": "user", "parts": [{"text": "hi"}]}} + """, + appName, USER_ID, sessionId)); + } + + private static Workflow greetingWorkflow() { + Node greet = new StepNode("greet"); + return Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, greet), new Edge(greet, new StepNode("done"))) + .build(); + } + + /** Records that its before-run callback ran. */ + private static final class RecordingPlugin extends BasePlugin { + RecordingPlugin(String name) { + super(name); + } + + @Override + public Maybe beforeRunCallback(InvocationContext invocationContext) { + beforeRunCalls.add(getName()); + return Maybe.empty(); + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/InMemoryAppLoaderTest.java b/dev/src/test/java/com/google/adk/web/InMemoryAppLoaderTest.java new file mode 100644 index 000000000..5ee511663 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/InMemoryAppLoaderTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 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.web; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.adk.apps.App; +import com.google.adk.web.testing.StepNode; +import org.junit.jupiter.api.Test; + +class InMemoryAppLoaderTest { + + private static final App FLOW_APP = + App.builder().name("flow").rootNode(new StepNode("a")).build(); + private static final App OTHER_APP = + App.builder().name("another").rootNode(new StepNode("b")).build(); + + @Test + void listApps_returnsTheAppNamesSorted() { + InMemoryAppLoader loader = new InMemoryAppLoader(FLOW_APP, OTHER_APP); + + assertThat(loader.listApps()).containsExactly("another", "flow").inOrder(); + } + + @Test + void loadApp_servedName_returnsTheApp() { + InMemoryAppLoader loader = new InMemoryAppLoader(FLOW_APP, OTHER_APP); + + assertThat(loader.loadApp("flow")).hasValue(FLOW_APP); + } + + @Test + void loadApp_unknownName_returnsEmpty() { + InMemoryAppLoader loader = new InMemoryAppLoader(FLOW_APP); + + assertThat(loader.loadApp("missing")).isEmpty(); + } + + @Test + void constructor_twoAppsWithOneName_throwsIllegalArgumentException() { + App sameName = App.builder().name("flow").rootNode(new StepNode("c")).build(); + + assertThrows(IllegalArgumentException.class, () -> new InMemoryAppLoader(FLOW_APP, sameName)); + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/AgentControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/AgentControllerTest.java new file mode 100644 index 000000000..cf08ccd07 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/AgentControllerTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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.web.controller; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AgentStaticLoader; +import org.junit.jupiter.api.Test; + +/** Tests the deprecated {@link AgentLoader} constructor, which Spring does not use. */ +@SuppressWarnings("deprecation") +class AgentControllerTest { + + @Test + void listApps_agentLoaderConstructor_returnsTheLoadersAgentsSorted() { + AgentController controller = + new AgentController(new AgentStaticLoader(agent("zeta"), agent("alpha"))); + + assertThat(controller.listApps()).containsExactly("alpha", "zeta").inOrder(); + } + + @Test + void agentLoaderConstructor_nullLoader_throwsNullPointerException() { + assertThrows(NullPointerException.class, () -> new AgentController((AgentLoader) null)); + } + + private static LlmAgent agent(String name) { + return LlmAgent.builder().name(name).model("gemini-2.5-flash").build(); + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/GraphControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/GraphControllerTest.java new file mode 100644 index 000000000..e54c58b53 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/GraphControllerTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 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.web.controller; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AgentStaticLoader; +import com.google.adk.web.dto.GraphResponse; +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** Tests the deprecated {@link AgentLoader} constructor, which Spring does not use. */ +@SuppressWarnings("deprecation") +class GraphControllerTest { + + private static final String USER_ID = "user"; + + private final InMemorySessionService sessionService = new InMemorySessionService(); + + @Test + void getEventGraph_agentLoaderConstructorKnownAgent_findsTheAgent() { + GraphController controller = + new GraphController( + sessionService, + new AgentStaticLoader( + LlmAgent.builder().name("assistant").model("gemini-2.5-flash").build())); + String sessionId = sessionService.createSession("assistant", USER_ID).blockingGet().id(); + + ResponseEntity response = + controller.getEventGraph("assistant", USER_ID, sessionId, "unknown_event"); + + // An unknown event gets an empty graph only once the app's root agent is found. + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void getEventGraph_agentLoaderConstructorUnknownApp_returnsNotFound() { + GraphController controller = new GraphController(sessionService, new AgentStaticLoader()); + + ResponseEntity response = + controller.getEventGraph("missing", USER_ID, "session", "event"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getEventGraph_agentLoaderConstructorAgentFailsToLoad_returnsServerError() { + AgentLoader failingLoader = + new AgentLoader() { + @Override + public ImmutableList listAgents() { + return ImmutableList.of("broken"); + } + + @Override + public BaseAgent loadAgent(String name) { + throw new IllegalStateException("cannot build the agent"); + } + }; + GraphController controller = new GraphController(sessionService, failingLoader); + + ResponseEntity response = + controller.getEventGraph("broken", USER_ID, "session", "event"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/dev/src/test/java/com/google/adk/web/service/RunnerServiceTest.java b/dev/src/test/java/com/google/adk/web/service/RunnerServiceTest.java new file mode 100644 index 000000000..3b44d49c1 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/service/RunnerServiceTest.java @@ -0,0 +1,324 @@ +/* + * Copyright 2026 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.web.service; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.apps.App; +import com.google.adk.artifacts.InMemoryArtifactService; +import com.google.adk.memory.InMemoryMemoryService; +import com.google.adk.plugins.BasePlugin; +import com.google.adk.runner.Runner; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.web.AgentLoader; +import com.google.adk.web.AppLoader; +import com.google.adk.web.CompiledAgentLoader; +import com.google.adk.web.InMemoryAppLoader; +import com.google.adk.web.config.AgentLoadingProperties; +import com.google.adk.web.testing.StepNode; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import com.google.common.collect.ImmutableList; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +class RunnerServiceTest { + + private static final Workflow WORKFLOW = + Workflow.builder().name("flow").edges(new Edge(Start.INSTANCE, new StepNode("step"))).build(); + private static final BaseAgent AGENT = + LlmAgent.builder().name("assistant").model("gemini-2.5-flash").build(); + private static final BasePlugin APP_PLUGIN = new BasePlugin("app_plugin") {}; + private static final BasePlugin SERVER_PLUGIN = new BasePlugin("server_plugin") {}; + private static final App FLOW_APP = + App.builder().name("flow_app").rootNode(WORKFLOW).plugins(APP_PLUGIN).build(); + private static final App AGENT_APP = App.builder().name("agent_app").rootAgent(AGENT).build(); + + @Test + void listApps_appLoader_returnsItsApps() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP, AGENT_APP), null); + + assertThat(service.listApps()).containsExactly("agent_app", "flow_app"); + } + + @Test + void loadRoot_workflowApp_returnsTheWorkflow() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + assertThat(service.loadRoot("flow_app")).hasValue(WORKFLOW); + } + + @Test + void loadRoot_agentApp_returnsTheRootAgent() { + RunnerService service = runnerService(new InMemoryAppLoader(AGENT_APP), null); + + assertThat(service.loadRoot("agent_app")).hasValue(AGENT); + } + + @Test + void loadRoot_unknownApp_returnsEmpty() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + assertThat(service.loadRoot("missing")).isEmpty(); + } + + @Test + void loadRoot_appFailsToLoad_throwsIllegalStateException() { + RunnerService service = runnerService(new FailingAppLoader(), null); + + assertThrows(IllegalStateException.class, () -> service.loadRoot("broken")); + } + + @Test + void getRunner_appLoader_runsTheAppWithItsPluginsBeforeTheServers() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null, SERVER_PLUGIN); + + Runner runner = service.getRunner("flow_app"); + + assertThat(runner.appName()).isEqualTo("flow_app"); + assertThat(runner.node()).hasValue(WORKFLOW); + assertThat(runner.pluginManager().getPlugins()) + .containsExactly(APP_PLUGIN, SERVER_PLUGIN) + .inOrder(); + } + + @Test + void getRunner_appServedUnderAnotherName_runsItUnderTheRequestedName() { + AppLoader loader = + new AppLoader() { + @Override + public ImmutableList listApps() { + return ImmutableList.of("alias"); + } + + @Override + public Optional loadApp(String appName) { + return appName.equals("alias") ? Optional.of(AGENT_APP) : Optional.empty(); + } + }; + RunnerService service = runnerService(loader, null); + + assertThat(service.getRunner("alias").appName()).isEqualTo("alias"); + } + + @Test + void getRunner_calledTwice_returnsTheCachedRunner() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + assertThat(service.getRunner("flow_app")).isSameInstanceAs(service.getRunner("flow_app")); + } + + @Test + void getRunner_unknownApp_throwsNotFound() { + RunnerService service = runnerService(new InMemoryAppLoader(FLOW_APP), null); + + ResponseStatusException e = + assertThrows(ResponseStatusException.class, () -> service.getRunner("missing")); + + assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(e.getReason()).isEqualTo("App not found."); + } + + @Test + void getRunner_appFailsToLoad_throwsServerError() { + RunnerService service = runnerService(new FailingAppLoader(), null); + + ResponseStatusException e = + assertThrows(ResponseStatusException.class, () -> service.getRunner("broken")); + + assertThat(e.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(e.getReason()).isEqualTo("App failed to load."); + } + + @Test + void getRunner_agentLoader_runsTheAgentWithoutAnAppUnderAnyName() { + RunnerService service = + runnerService(null, new SingleAgentLoader("support.bot"), SERVER_PLUGIN); + + Runner runner = service.getRunner("support.bot"); + + assertThat(runner.appName()).isEqualTo("support.bot"); + assertThat(runner.agent()).isSameInstanceAs(AGENT); + assertThat(runner.pluginManager().getPlugins()).containsExactly(SERVER_PLUGIN); + } + + @Test + void getRunner_agentLoaderUnknownName_throwsNotFound() { + RunnerService service = runnerService(null, new SingleAgentLoader("support.bot")); + + ResponseStatusException e = + assertThrows(ResponseStatusException.class, () -> service.getRunner("missing")); + + assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(e.getReason()).isEqualTo("App not found."); + } + + @Test + void loadRoot_agentFailsToLoad_throwsIllegalStateException() { + AgentLoader loader = + new AgentLoader() { + @Override + public ImmutableList listAgents() { + return ImmutableList.of("broken"); + } + + @Override + public BaseAgent loadAgent(String name) { + throw new IllegalArgumentException("cannot build the agent"); + } + }; + RunnerService service = runnerService(null, loader); + + assertThrows(IllegalStateException.class, () -> service.loadRoot("broken")); + } + + @Test + void appLoaderConstructor_servesTheAppLoader() { + RunnerService service = + new RunnerService( + new InMemoryAppLoader(FLOW_APP), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of()); + + assertThat(service.listApps()).containsExactly("flow_app"); + assertThat(service.loadRoot("flow_app")).hasValue(WORKFLOW); + } + + @Test + void agentLoaderConstructor_servesTheAgentLoader() { + RunnerService service = + new RunnerService( + new SingleAgentLoader("support.bot"), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of()); + + assertThat(service.listApps()).containsExactly("support.bot"); + assertThat(service.loadRoot("support.bot")).hasValue(AGENT); + } + + @Test + void constructor_appLoaderAndDefaultCompiledLoader_servesTheAppLoader() { + RunnerService service = + runnerService( + new InMemoryAppLoader(FLOW_APP), new CompiledAgentLoader(new AgentLoadingProperties())); + + assertThat(service.listApps()).containsExactly("flow_app"); + } + + @Test + void constructor_appLoaderAndAnotherAgentLoader_throwsIllegalStateException() { + assertThrows( + IllegalStateException.class, + () -> runnerService(new InMemoryAppLoader(FLOW_APP), new SingleAgentLoader("support.bot"))); + } + + @Test + void constructor_appLoaderAndAnotherAgentLoaderNextToThePrimaryDefault_throwsIllegalState() { + DefaultListableBeanFactory beans = new DefaultListableBeanFactory(); + beans.registerSingleton("appLoader", new InMemoryAppLoader(FLOW_APP)); + // Registered like the server's default loader: a @Primary bean definition. + RootBeanDefinition compiled = + new RootBeanDefinition( + CompiledAgentLoader.class, () -> new CompiledAgentLoader(new AgentLoadingProperties())); + compiled.setPrimary(true); + beans.registerBeanDefinition("agentLoader", compiled); + beans.registerSingleton("customAgentLoader", new SingleAgentLoader("support.bot")); + + assertThrows( + IllegalStateException.class, + () -> + new RunnerService( + beans.getBeanProvider(AppLoader.class), + beans.getBeanProvider(AgentLoader.class), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.of())); + } + + @Test + void constructor_noLoader_throwsIllegalStateException() { + assertThrows(IllegalStateException.class, () -> runnerService(null, null)); + } + + private static RunnerService runnerService( + @Nullable AppLoader appLoader, @Nullable AgentLoader agentLoader, BasePlugin... plugins) { + DefaultListableBeanFactory beans = new DefaultListableBeanFactory(); + if (appLoader != null) { + beans.registerSingleton("appLoader", appLoader); + } + if (agentLoader != null) { + beans.registerSingleton("agentLoader", agentLoader); + } + return new RunnerService( + beans.getBeanProvider(AppLoader.class), + beans.getBeanProvider(AgentLoader.class), + new InMemoryArtifactService(), + new InMemorySessionService(), + new InMemoryMemoryService(), + ImmutableList.copyOf(plugins)); + } + + /** Serves {@link #AGENT} under one name. */ + private static final class SingleAgentLoader implements AgentLoader { + private final String name; + + SingleAgentLoader(String name) { + this.name = name; + } + + @Override + public ImmutableList listAgents() { + return ImmutableList.of(name); + } + + @Override + public BaseAgent loadAgent(String name) { + if (!name.equals(this.name)) { + throw new NoSuchElementException(); + } + return AGENT; + } + } + + /** Lists one app that fails to load. */ + private static final class FailingAppLoader implements AppLoader { + @Override + public ImmutableList listApps() { + return ImmutableList.of("broken"); + } + + @Override + public Optional loadApp(String appName) { + throw new IllegalArgumentException("cannot build the app"); + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/testing/StepNode.java b/dev/src/test/java/com/google/adk/web/testing/StepNode.java new file mode 100644 index 000000000..acbb27c7d --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/testing/StepNode.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 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.web.testing; + +import com.google.adk.agents.Context; +import com.google.adk.workflow.BaseNode; +import io.reactivex.rxjava3.core.Flowable; +import org.jspecify.annotations.Nullable; + +/** A workflow node that outputs its own name, for tests that run a workflow without a model. */ +public final class StepNode extends BaseNode { + + public StepNode(String name) { + super(name, ""); + } + + @Override + public Flowable runNode(Context context, @Nullable Object nodeInput) { + return Flowable.just(name()); + } +}