From 8673f467b3c612835f84910af1bdf248bc9539c6 Mon Sep 17 00:00:00 2001 From: ddobrin Date: Fri, 5 Dec 2025 10:22:54 -0500 Subject: [PATCH 1/3] chore: Update Spring AI to 1.1.1 --- contrib/spring-ai/README.md | 4 ++-- contrib/spring-ai/pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/spring-ai/README.md b/contrib/spring-ai/README.md index c45f0e033..588b3d122 100644 --- a/contrib/spring-ai/README.md +++ b/contrib/spring-ai/README.md @@ -32,7 +32,7 @@ To use ADK Java with the Spring AI integration in your application, add the foll org.springframework.ai spring-ai-bom - 1.1.0-M3 + 1.1.1 pom import @@ -115,7 +115,7 @@ Add the Spring AI provider dependencies for the AI services you want to use: 17 - 1.1.0-M3 + 1.1.1 0.3.1-SNAPSHOT diff --git a/contrib/spring-ai/pom.xml b/contrib/spring-ai/pom.xml index 9f59203bb..4144688b3 100644 --- a/contrib/spring-ai/pom.xml +++ b/contrib/spring-ai/pom.xml @@ -29,7 +29,7 @@ Spring AI integration for the Agent Development Kit. - 1.1.0 + 1.1.1 1.21.3 From d3c68023100d8c4c60d4bc7a4129ab875d8aadfa Mon Sep 17 00:00:00 2001 From: ddobrin Date: Tue, 3 Mar 2026 11:42:38 -0500 Subject: [PATCH 2/3] Planner modeule initial codebase --- contrib/planners/pom.xml | 65 +++++ .../com/google/adk/planner/LoopPlanner.java | 90 +++++++ .../google/adk/planner/ParallelPlanner.java | 39 +++ .../google/adk/planner/SequentialPlanner.java | 57 ++++ .../google/adk/planner/SupervisorPlanner.java | 178 +++++++++++++ .../adk/planner/goap/AgentMetadata.java | 32 +++ .../planner/goap/DependencyGraphSearch.java | 96 +++++++ .../adk/planner/goap/GoalOrientedPlanner.java | 95 +++++++ .../planner/goap/GoalOrientedSearchGraph.java | 69 +++++ .../adk/planner/p2p/AgentActivator.java | 70 +++++ .../google/adk/planner/p2p/P2PPlanner.java | 157 +++++++++++ .../adk/planner/SupervisorPlannerTest.java | 169 ++++++++++++ .../planner/goap/GoalOrientedPlannerTest.java | 226 ++++++++++++++++ .../adk/planner/p2p/P2PPlannerTest.java | 243 ++++++++++++++++++ tutorials/planner-agents/pom.xml | 56 ++++ .../planner/goap/HoroscopeWriterExample.java | 143 +++++++++++ .../planner/goap/StoryWriterExample.java | 156 +++++++++++ .../p2p/ResearchCollaborationExample.java | 156 +++++++++++ .../planner/supervisor/SupervisorExample.java | 86 +++++++ 19 files changed, 2183 insertions(+) create mode 100644 contrib/planners/pom.xml create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/LoopPlanner.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/ParallelPlanner.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/SequentialPlanner.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/SupervisorPlanner.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/goap/AgentMetadata.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/goap/DependencyGraphSearch.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedPlanner.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedSearchGraph.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/p2p/AgentActivator.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/p2p/P2PPlanner.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/SupervisorPlannerTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/goap/GoalOrientedPlannerTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/p2p/P2PPlannerTest.java create mode 100644 tutorials/planner-agents/pom.xml create mode 100644 tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/HoroscopeWriterExample.java create mode 100644 tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/StoryWriterExample.java create mode 100644 tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/p2p/ResearchCollaborationExample.java create mode 100644 tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/supervisor/SupervisorExample.java diff --git a/contrib/planners/pom.xml b/contrib/planners/pom.xml new file mode 100644 index 000000000..85699c313 --- /dev/null +++ b/contrib/planners/pom.xml @@ -0,0 +1,65 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-parent + 0.5.1-SNAPSHOT + ../../pom.xml + + + google-adk-planners + Agent Development Kit - Planners + Built-in planner implementations for the ADK PlannerAgent, including GOAP (Goal-Oriented Action Planning), P2P (Peer-to-Peer), and Supervisor planners. + + + + + com.google.adk + google-adk + ${project.version} + + + com.google.genai + google-genai + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + com.google.truth + truth + test + + + org.mockito + mockito-core + test + + + diff --git a/contrib/planners/src/main/java/com/google/adk/planner/LoopPlanner.java b/contrib/planners/src/main/java/com/google/adk/planner/LoopPlanner.java new file mode 100644 index 000000000..112328601 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/LoopPlanner.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.Planner; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.adk.events.Event; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A planner that cycles through sub-agents repeatedly, stopping when an escalate event is detected + * or the maximum number of cycles is reached. + */ +public final class LoopPlanner implements Planner { + + private final int maxCycles; + private final AtomicInteger cursor = new AtomicInteger(0); + private final AtomicInteger cycleCount = new AtomicInteger(0); + private ImmutableList agents; + + public LoopPlanner(int maxCycles) { + this.maxCycles = maxCycles; + } + + @Override + public void init(PlanningContext context) { + agents = context.availableAgents(); + cursor.set(0); + cycleCount.set(0); + } + + @Override + public Single firstAction(PlanningContext context) { + cursor.set(0); + cycleCount.set(0); + return selectNext(context); + } + + @Override + public Single nextAction(PlanningContext context) { + if (hasEscalateEvent(context.events())) { + return Single.just(new PlannerAction.Done()); + } + return selectNext(context); + } + + private Single selectNext(PlanningContext context) { + if (agents == null || agents.isEmpty()) { + return Single.just(new PlannerAction.Done()); + } + + int idx = cursor.getAndIncrement(); + if (idx >= agents.size()) { + int cycle = cycleCount.incrementAndGet(); + if (cycle >= maxCycles) { + return Single.just(new PlannerAction.Done()); + } + cursor.set(1); + idx = 0; + } + return Single.just(new PlannerAction.RunAgents(agents.get(idx))); + } + + private static boolean hasEscalateEvent(List events) { + if (events.isEmpty()) { + return false; + } + Event lastEvent = events.get(events.size() - 1); + return lastEvent.actions().escalate().orElse(false); + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/ParallelPlanner.java b/contrib/planners/src/main/java/com/google/adk/planner/ParallelPlanner.java new file mode 100644 index 000000000..ec6e5c909 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/ParallelPlanner.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner; + +import com.google.adk.agents.Planner; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import io.reactivex.rxjava3.core.Single; + +/** A planner that runs all sub-agents in parallel, then completes. */ +public final class ParallelPlanner implements Planner { + + @Override + public Single firstAction(PlanningContext context) { + if (context.availableAgents().isEmpty()) { + return Single.just(new PlannerAction.Done()); + } + return Single.just(new PlannerAction.RunAgents(context.availableAgents())); + } + + @Override + public Single nextAction(PlanningContext context) { + return Single.just(new PlannerAction.Done()); + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/SequentialPlanner.java b/contrib/planners/src/main/java/com/google/adk/planner/SequentialPlanner.java new file mode 100644 index 000000000..7953b0e8f --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/SequentialPlanner.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.Planner; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Single; +import java.util.concurrent.atomic.AtomicInteger; + +/** A planner that runs sub-agents one at a time in order. */ +public final class SequentialPlanner implements Planner { + + private final AtomicInteger cursor = new AtomicInteger(0); + private ImmutableList agents; + + @Override + public void init(PlanningContext context) { + agents = context.availableAgents(); + cursor.set(0); + } + + @Override + public Single firstAction(PlanningContext context) { + cursor.set(0); + return selectNext(); + } + + @Override + public Single nextAction(PlanningContext context) { + return selectNext(); + } + + private Single selectNext() { + int idx = cursor.getAndIncrement(); + if (agents == null || idx >= agents.size()) { + return Single.just(new PlannerAction.Done()); + } + return Single.just(new PlannerAction.RunAgents(agents.get(idx))); + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/SupervisorPlanner.java b/contrib/planners/src/main/java/com/google/adk/planner/SupervisorPlanner.java new file mode 100644 index 000000000..0a75b54c1 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/SupervisorPlanner.java @@ -0,0 +1,178 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.Planner; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.adk.events.Event; +import com.google.adk.models.BaseLlm; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A planner that uses an LLM to dynamically decide which sub-agent(s) to run next. + * + *

The LLM is given a system prompt describing the available agents and their descriptions, the + * current state, and recent events. It responds with the agent name(s) to run, "DONE", or "DONE: + * summary". + */ +public final class SupervisorPlanner implements Planner { + + private static final Logger logger = LoggerFactory.getLogger(SupervisorPlanner.class); + + private final BaseLlm llm; + private final Optional systemInstruction; + + public SupervisorPlanner(BaseLlm llm, String systemInstruction) { + this.llm = llm; + this.systemInstruction = Optional.ofNullable(systemInstruction); + } + + public SupervisorPlanner(BaseLlm llm) { + this(llm, null); + } + + @Override + public Single firstAction(PlanningContext context) { + return askLlm(context); + } + + @Override + public Single nextAction(PlanningContext context) { + return askLlm(context); + } + + private Single askLlm(PlanningContext context) { + String prompt = buildPrompt(context); + LlmRequest.Builder requestBuilder = + LlmRequest.builder() + .contents( + ImmutableList.of( + Content.builder().role("user").parts(Part.fromText(prompt)).build())); + systemInstruction.ifPresent( + si -> + requestBuilder.config( + GenerateContentConfig.builder() + .systemInstruction(Content.fromParts(Part.fromText(si))) + .build())); + LlmRequest request = requestBuilder.build(); + + return llm.generateContent(request, false) + .lastOrError() + .map( + response -> { + String text = extractText(response); + return parseResponse(text, context); + }) + .onErrorReturn( + error -> { + logger.warn("LLM call failed in SupervisorPlanner, returning Done", error); + return new PlannerAction.Done(); + }); + } + + private String buildPrompt(PlanningContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("You are a supervisor deciding which agent to run next.\n\n"); + sb.append("Available agents:\n"); + for (BaseAgent agent : context.availableAgents()) { + sb.append("- ").append(agent.name()).append(": ").append(agent.description()).append("\n"); + } + sb.append("\nCurrent state keys: ").append(context.state().keySet()).append("\n"); + + List events = context.events(); + if (!events.isEmpty()) { + sb.append("\nRecent events:\n"); + int start = Math.max(0, events.size() - 5); + for (int i = start; i < events.size(); i++) { + Event event = events.get(i); + sb.append("- ") + .append(event.author()) + .append(": ") + .append(event.stringifyContent()) + .append("\n"); + } + } + + context + .userContent() + .ifPresent( + content -> sb.append("\nOriginal user request: ").append(content.text()).append("\n")); + + sb.append( + "\nRespond with exactly one of:\n" + + "- The name of the agent to run next\n" + + "- Multiple agent names separated by commas (to run in parallel)\n" + + "- DONE (if the task is complete)\n" + + "- DONE:

(if complete with a summary)\n" + + "\nRespond with only the agent name(s) or DONE, nothing else."); + return sb.toString(); + } + + private String extractText(LlmResponse response) { + return response.content().flatMap(Content::parts).stream() + .flatMap(List::stream) + .flatMap(part -> part.text().stream()) + .collect(Collectors.joining()) + .trim(); + } + + private PlannerAction parseResponse(String text, PlanningContext context) { + if (text.isEmpty()) { + return new PlannerAction.Done(); + } + + String upper = text.toUpperCase().trim(); + if (upper.equals("DONE")) { + return new PlannerAction.Done(); + } + if (upper.startsWith("DONE:")) { + String summary = text.substring(text.indexOf(':') + 1).trim(); + return new PlannerAction.DoneWithResult(summary); + } + + // Try to parse as agent name(s) + String[] parts = text.split(","); + ImmutableList.Builder agentsBuilder = ImmutableList.builder(); + for (String part : parts) { + String agentName = part.trim(); + try { + agentsBuilder.add(context.findAgent(agentName)); + } catch (IllegalArgumentException e) { + logger.warn("LLM returned unknown agent name '{}', treating as Done", agentName); + return new PlannerAction.Done(); + } + } + ImmutableList agents = agentsBuilder.build(); + if (agents.isEmpty()) { + return new PlannerAction.Done(); + } + return new PlannerAction.RunAgents(agents); + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/goap/AgentMetadata.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/AgentMetadata.java new file mode 100644 index 000000000..5280a35aa --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/AgentMetadata.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.goap; + +import com.google.common.collect.ImmutableList; + +/** + * Declares what state keys an agent reads (inputs) and writes (output). + * + *

Used by {@link GoalOrientedPlanner} and {@link com.google.adk.planner.p2p.P2PPlanner} for + * dependency resolution. + * + * @param agentName the name of the agent (must match {@link + * com.google.adk.agents.BaseAgent#name()}) + * @param inputKeys the state keys this agent reads as inputs + * @param outputKey the state key this agent produces as output + */ +public record AgentMetadata(String agentName, ImmutableList inputKeys, String outputKey) {} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/goap/DependencyGraphSearch.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/DependencyGraphSearch.java new file mode 100644 index 000000000..2f8caf5a3 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/DependencyGraphSearch.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.goap; + +import com.google.common.collect.ImmutableList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Performs a topological search on the dependency graph to find the ordered list of agents that + * must execute to produce a goal output, given a set of initial preconditions (state keys already + * available). + * + *

The search works backward from the goal: for each unsatisfied dependency, it finds the agent + * that produces it and recursively resolves that agent's dependencies. Uses recursive DFS to ensure + * correct topological ordering. + */ +public final class DependencyGraphSearch { + + private DependencyGraphSearch() {} + + /** + * Finds the ordered list of agent names that must execute to produce the goal. + * + * @param graph the dependency graph built from agent metadata + * @param preconditions state keys already available (no agent needed to produce them) + * @param goal the target output key to produce + * @return ordered list of agent names, from first to execute to last + * @throws IllegalStateException if a dependency cannot be resolved or a cycle is detected + */ + public static ImmutableList search( + GoalOrientedSearchGraph graph, Collection preconditions, String goal) { + + Set satisfied = new HashSet<>(preconditions); + LinkedHashSet executionOrder = new LinkedHashSet<>(); + Set visiting = new HashSet<>(); + + resolve(graph, goal, satisfied, visiting, executionOrder); + + return ImmutableList.copyOf(executionOrder); + } + + private static void resolve( + GoalOrientedSearchGraph graph, + String outputKey, + Set satisfied, + Set visiting, + LinkedHashSet executionOrder) { + + if (satisfied.contains(outputKey)) { + return; + } + + if (!graph.contains(outputKey)) { + throw new IllegalStateException( + "Cannot resolve dependency '" + + outputKey + + "': no agent produces this output key. " + + "Check that all required AgentMetadata entries are provided."); + } + + if (!visiting.add(outputKey)) { + throw new IllegalStateException( + "Circular dependency detected involving output key: " + outputKey); + } + + // Recursively resolve all dependencies first + for (String dep : graph.getDependencies(outputKey)) { + resolve(graph, dep, satisfied, visiting, executionOrder); + } + + // All dependencies are now satisfied; add this agent + String agentName = graph.getProducerAgent(outputKey); + if (agentName != null) { + executionOrder.add(agentName); + } + satisfied.add(outputKey); + visiting.remove(outputKey); + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedPlanner.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedPlanner.java new file mode 100644 index 000000000..acf7e733c --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedPlanner.java @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.goap; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.Planner; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A planner that resolves agent execution order based on input/output dependencies and a target + * goal (output key). + * + *

Given agent metadata declaring what each agent reads (inputKeys) and writes (outputKey), this + * planner uses backward-chaining dependency resolution to compute the execution path from initial + * preconditions to the goal. + * + *

Example: + * + *

+ *   Agent A: inputs=[], output="person"
+ *   Agent B: inputs=[], output="sign"
+ *   Agent C: inputs=["person", "sign"], output="horoscope"
+ *   Agent D: inputs=["person", "horoscope"], output="writeup"
+ *   Goal: "writeup"
+ *
+ *   Resolved path: A → B → C → D
+ * 
+ */ +public final class GoalOrientedPlanner implements Planner { + + private static final Logger logger = LoggerFactory.getLogger(GoalOrientedPlanner.class); + + private final String goal; + private final List metadata; + private ImmutableList executionPath; + private final AtomicInteger cursor = new AtomicInteger(0); + + public GoalOrientedPlanner(String goal, List metadata) { + this.goal = goal; + this.metadata = metadata; + } + + @Override + public void init(PlanningContext context) { + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList agentOrder = + DependencyGraphSearch.search(graph, context.state().keySet(), goal); + + logger.info("GoalOrientedPlanner resolved execution order: {}", agentOrder); + + executionPath = + agentOrder.stream().map(context::findAgent).collect(ImmutableList.toImmutableList()); + cursor.set(0); + } + + @Override + public Single firstAction(PlanningContext context) { + cursor.set(0); + return selectNext(); + } + + @Override + public Single nextAction(PlanningContext context) { + return selectNext(); + } + + private Single selectNext() { + int idx = cursor.getAndIncrement(); + if (executionPath == null || idx >= executionPath.size()) { + return Single.just(new PlannerAction.Done()); + } + return Single.just(new PlannerAction.RunAgents(executionPath.get(idx))); + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedSearchGraph.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedSearchGraph.java new file mode 100644 index 000000000..95a217d59 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedSearchGraph.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.goap; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.List; + +/** + * Transforms {@link AgentMetadata} into a dependency graph where: + * + *
    + *
  • Each output key maps to the agent that produces it + *
  • Each output key maps to the input keys (dependencies) required to produce it + *
+ * + *

Used by {@link DependencyGraphSearch} for A* search. + */ +public final class GoalOrientedSearchGraph { + + private final ImmutableMap outputKeyToAgent; + private final ImmutableMap> outputKeyToDependencies; + + public GoalOrientedSearchGraph(List metadata) { + ImmutableMap.Builder agentMap = ImmutableMap.builder(); + ImmutableMap.Builder> depMap = ImmutableMap.builder(); + + for (AgentMetadata m : metadata) { + agentMap.put(m.outputKey(), m.agentName()); + depMap.put(m.outputKey(), m.inputKeys()); + } + + this.outputKeyToAgent = agentMap.buildOrThrow(); + this.outputKeyToDependencies = depMap.buildOrThrow(); + } + + /** Returns the input keys (dependencies) needed to produce the given output key. */ + public ImmutableList getDependencies(String outputKey) { + ImmutableList deps = outputKeyToDependencies.get(outputKey); + if (deps == null) { + return ImmutableList.of(); + } + return deps; + } + + /** Returns the agent name that produces the given output key. */ + public String getProducerAgent(String outputKey) { + return outputKeyToAgent.get(outputKey); + } + + /** Returns true if the given output key is known in this graph. */ + public boolean contains(String outputKey) { + return outputKeyToAgent.containsKey(outputKey); + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/p2p/AgentActivator.java b/contrib/planners/src/main/java/com/google/adk/planner/p2p/AgentActivator.java new file mode 100644 index 000000000..aae9e3e68 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/p2p/AgentActivator.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.p2p; + +import com.google.adk.planner.goap.AgentMetadata; +import java.util.concurrent.ConcurrentMap; + +/** + * Tracks activation state for a single agent in P2P planning. + * + *

An agent can activate when: it is not currently executing, it is marked as should-execute, and + * all its input keys are present in the session state. + */ +final class AgentActivator { + + private final AgentMetadata metadata; + private boolean executing = false; + private boolean shouldExecute = true; + + AgentActivator(AgentMetadata metadata) { + this.metadata = metadata; + } + + /** Returns the agent name this activator manages. */ + String agentName() { + return metadata.agentName(); + } + + /** Returns true if the agent can be activated given the current state. */ + boolean canActivate(ConcurrentMap state) { + return !executing + && shouldExecute + && metadata.inputKeys().stream().allMatch(state::containsKey); + } + + /** Marks the agent as currently executing. */ + void startExecution() { + executing = true; + shouldExecute = false; + } + + /** Marks the agent as finished executing. */ + void finishExecution() { + executing = false; + } + + /** + * Called when another agent produces output. If the produced key is one of this agent's inputs, + * marks this agent for re-execution. + */ + void onStateChanged(String producedKey) { + if (metadata.inputKeys().contains(producedKey)) { + shouldExecute = true; + } + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/p2p/P2PPlanner.java b/contrib/planners/src/main/java/com/google/adk/planner/p2p/P2PPlanner.java new file mode 100644 index 000000000..735286026 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/p2p/P2PPlanner.java @@ -0,0 +1,157 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.p2p; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.Planner; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.adk.planner.goap.AgentMetadata; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Single; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiPredicate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A peer-to-peer planner where agents activate dynamically as their input dependencies become + * available in session state. + * + *

Key behaviors: + * + *

    + *
  • Multiple agents can activate in parallel when their inputs are satisfied + *
  • When an agent produces output, other agents whose inputs are now satisfied activate + *
  • Agents can re-execute when their inputs change (iterative refinement) + *
  • Terminates on maxInvocations or a custom exit condition + *
+ * + *

Example: Research collaboration where a critic's feedback causes hypothesis refinement: + * + *

+ *   LiteratureAgent (needs: topic) → researchFindings
+ *   HypothesisAgent (needs: topic, researchFindings) → hypothesis
+ *   CriticAgent (needs: topic, hypothesis) → critique
+ *   ScorerAgent (needs: topic, hypothesis, critique) → score
+ *   Exit when: score >= 0.85
+ * 
+ */ +public final class P2PPlanner implements Planner { + + private static final Logger logger = LoggerFactory.getLogger(P2PPlanner.class); + + private final List metadata; + private final int maxInvocations; + private final BiPredicate, Integer> exitCondition; + private Map activators; + private final AtomicInteger invocationCount = new AtomicInteger(0); + + /** + * Creates a P2P planner with a custom exit condition. + * + * @param metadata agent input/output declarations + * @param maxInvocations maximum total agent invocations before termination + * @param exitCondition predicate tested on (state, invocationCount); returns true to stop + */ + public P2PPlanner( + List metadata, + int maxInvocations, + BiPredicate, Integer> exitCondition) { + this.metadata = metadata; + this.maxInvocations = maxInvocations; + this.exitCondition = exitCondition; + } + + /** Creates a P2P planner that exits only on maxInvocations. */ + public P2PPlanner(List metadata, int maxInvocations) { + this(metadata, maxInvocations, (state, count) -> false); + } + + @Override + public void init(PlanningContext context) { + activators = new LinkedHashMap<>(); + for (AgentMetadata m : metadata) { + activators.put(m.agentName(), new AgentActivator(m)); + } + invocationCount.set(0); + } + + @Override + public Single firstAction(PlanningContext context) { + return findReadyAgents(context); + } + + @Override + public Single nextAction(PlanningContext context) { + int count = invocationCount.get(); + + // Check exit condition + if (exitCondition.test(context.state(), count)) { + logger.info("P2PPlanner exit condition met at invocation {}", count); + return Single.just(new PlannerAction.Done()); + } + + // Mark previously executing agents as finished and notify state changes + for (AgentActivator activator : activators.values()) { + activator.finishExecution(); + } + + // Notify all activators about state changes from recently produced keys + for (AgentMetadata m : metadata) { + if (context.state().containsKey(m.outputKey())) { + for (AgentActivator activator : activators.values()) { + activator.onStateChanged(m.outputKey()); + } + } + } + + return findReadyAgents(context); + } + + private Single findReadyAgents(PlanningContext context) { + if (invocationCount.get() >= maxInvocations) { + logger.info("P2PPlanner reached maxInvocations={}", maxInvocations); + return Single.just(new PlannerAction.Done()); + } + + ImmutableList.Builder readyAgents = ImmutableList.builder(); + for (AgentActivator activator : activators.values()) { + if (activator.canActivate(context.state())) { + readyAgents.add(context.findAgent(activator.agentName())); + activator.startExecution(); + invocationCount.incrementAndGet(); + } + } + + ImmutableList agents = readyAgents.build(); + if (agents.isEmpty()) { + logger.info("P2PPlanner: no agents can activate, done"); + return Single.just(new PlannerAction.Done()); + } + + logger.info( + "P2PPlanner activating {} agent(s): {}", + agents.size(), + agents.stream().map(BaseAgent::name).toList()); + return Single.just(new PlannerAction.RunAgents(agents)); + } +} diff --git a/contrib/planners/src/test/java/com/google/adk/planner/SupervisorPlannerTest.java b/contrib/planners/src/test/java/com/google/adk/planner/SupervisorPlannerTest.java new file mode 100644 index 000000000..9c65277f6 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/SupervisorPlannerTest.java @@ -0,0 +1,169 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.InvocationContext; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.adk.events.Event; +import com.google.adk.models.BaseLlm; +import com.google.adk.models.LlmResponse; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.sessions.Session; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Flowable; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link SupervisorPlanner}. */ +class SupervisorPlannerTest { + + private static final class SimpleTestAgent extends BaseAgent { + SimpleTestAgent(String name) { + super(name, "test agent " + name, ImmutableList.of(), null, null); + } + + @Override + protected Flowable runAsyncImpl(InvocationContext ctx) { + return Flowable.empty(); + } + + @Override + protected Flowable runLiveImpl(InvocationContext ctx) { + return Flowable.empty(); + } + } + + @Test + void firstAction_parsesAgentNameFromLlm() { + BaseLlm mockLlm = mock(BaseLlm.class); + LlmResponse response = createTextResponse("agentA"); + when(mockLlm.generateContent(any(), eq(false))).thenReturn(Flowable.just(response)); + + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + SupervisorPlanner planner = new SupervisorPlanner(mockLlm, "You are a supervisor."); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB), new ConcurrentHashMap<>()); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + PlannerAction.RunAgents runAgents = (PlannerAction.RunAgents) action; + assertThat(runAgents.agents()).hasSize(1); + assertThat(runAgents.agents().get(0).name()).isEqualTo("agentA"); + } + + @Test + void firstAction_parsesDoneFromLlm() { + BaseLlm mockLlm = mock(BaseLlm.class); + LlmResponse response = createTextResponse("DONE"); + when(mockLlm.generateContent(any(), eq(false))).thenReturn(Flowable.just(response)); + + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + SupervisorPlanner planner = new SupervisorPlanner(mockLlm); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void firstAction_parsesDoneWithResultFromLlm() { + BaseLlm mockLlm = mock(BaseLlm.class); + LlmResponse response = createTextResponse("DONE: Task completed successfully"); + when(mockLlm.generateContent(any(), eq(false))).thenReturn(Flowable.just(response)); + + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + SupervisorPlanner planner = new SupervisorPlanner(mockLlm); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.DoneWithResult.class); + assertThat(((PlannerAction.DoneWithResult) action).result()) + .isEqualTo("Task completed successfully"); + } + + @Test + void nextAction_fallsToDoneOnUnrecognizedAgent() { + BaseLlm mockLlm = mock(BaseLlm.class); + LlmResponse response = createTextResponse("unknownAgent"); + when(mockLlm.generateContent(any(), eq(false))).thenReturn(Flowable.just(response)); + + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + SupervisorPlanner planner = new SupervisorPlanner(mockLlm); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void firstAction_fallsToDoneOnLlmError() { + BaseLlm mockLlm = mock(BaseLlm.class); + when(mockLlm.generateContent(any(), eq(false))) + .thenReturn(Flowable.error(new RuntimeException("LLM error"))); + + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + SupervisorPlanner planner = new SupervisorPlanner(mockLlm); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + private static LlmResponse createTextResponse(String text) { + return LlmResponse.builder() + .content(Content.builder().role("model").parts(Part.fromText(text)).build()) + .build(); + } + + private static PlanningContext createPlanningContext( + ImmutableList agents, ConcurrentHashMap state) { + InMemorySessionService sessionService = new InMemorySessionService(); + Session session = sessionService.createSession("test-app", "test-user").blockingGet(); + session.state().putAll(state); + + BaseAgent rootAgent = agents.isEmpty() ? new SimpleTestAgent("root") : agents.get(0); + InvocationContext invocationContext = + InvocationContext.builder() + .sessionService(sessionService) + .invocationId("test-invocation") + .agent(rootAgent) + .session(session) + .build(); + + return new PlanningContext(invocationContext, agents); + } +} diff --git a/contrib/planners/src/test/java/com/google/adk/planner/goap/GoalOrientedPlannerTest.java b/contrib/planners/src/test/java/com/google/adk/planner/goap/GoalOrientedPlannerTest.java new file mode 100644 index 000000000..b6c0fcb16 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/goap/GoalOrientedPlannerTest.java @@ -0,0 +1,226 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.goap; + +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.InvocationContext; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.adk.events.Event; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Flowable; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link GoalOrientedPlanner}, {@link DependencyGraphSearch}, and related classes. + */ +class GoalOrientedPlannerTest { + + /** Simple test agent for planner tests. */ + private static final class SimpleTestAgent extends BaseAgent { + SimpleTestAgent(String name) { + super(name, "test agent " + name, ImmutableList.of(), null, null); + } + + @Override + protected Flowable runAsyncImpl(InvocationContext ctx) { + return Flowable.empty(); + } + + @Override + protected Flowable runLiveImpl(InvocationContext ctx) { + return Flowable.empty(); + } + } + + @Test + void dependencyGraphSearch_resolvesLinearChain() { + // A → B → C (linear chain) + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of(), "outputA"), + new AgentMetadata("agentB", ImmutableList.of("outputA"), "outputB"), + new AgentMetadata("agentC", ImmutableList.of("outputB"), "outputC")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList path = DependencyGraphSearch.search(graph, List.of(), "outputC"); + + assertThat(path).containsExactly("agentA", "agentB", "agentC").inOrder(); + } + + @Test + void dependencyGraphSearch_resolvesMultipleInputs() { + // A and B have no deps; C needs both A and B outputs + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of(), "outputA"), + new AgentMetadata("agentB", ImmutableList.of(), "outputB"), + new AgentMetadata("agentC", ImmutableList.of("outputA", "outputB"), "outputC")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList path = DependencyGraphSearch.search(graph, List.of(), "outputC"); + + assertThat(path).containsExactly("agentA", "agentB", "agentC"); + // C must come after both A and B + assertThat(path.indexOf("agentC")).isGreaterThan(path.indexOf("agentA")); + assertThat(path.indexOf("agentC")).isGreaterThan(path.indexOf("agentB")); + } + + @Test + void dependencyGraphSearch_handlesDiamondDependencies() { + // A → B, A → C, B+C → D (diamond) + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of(), "outputA"), + new AgentMetadata("agentB", ImmutableList.of("outputA"), "outputB"), + new AgentMetadata("agentC", ImmutableList.of("outputA"), "outputC"), + new AgentMetadata("agentD", ImmutableList.of("outputB", "outputC"), "outputD")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList path = DependencyGraphSearch.search(graph, List.of(), "outputD"); + + assertThat(path).containsExactly("agentA", "agentB", "agentC", "agentD"); + assertThat(path.indexOf("agentA")).isLessThan(path.indexOf("agentB")); + assertThat(path.indexOf("agentA")).isLessThan(path.indexOf("agentC")); + assertThat(path.indexOf("agentD")).isGreaterThan(path.indexOf("agentB")); + assertThat(path.indexOf("agentD")).isGreaterThan(path.indexOf("agentC")); + } + + @Test + void dependencyGraphSearch_skipsSatisfiedPreconditions() { + // A and B produce outputs; C needs both. + // But outputA is already available as a precondition. + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of(), "outputA"), + new AgentMetadata("agentB", ImmutableList.of(), "outputB"), + new AgentMetadata("agentC", ImmutableList.of("outputA", "outputB"), "outputC")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList path = DependencyGraphSearch.search(graph, List.of("outputA"), "outputC"); + + // agentA should be skipped since outputA is already available + assertThat(path).containsExactly("agentB", "agentC").inOrder(); + } + + @Test + void dependencyGraphSearch_throwsOnUnresolvableDependency() { + // agentB needs "missing" which no agent produces + List metadata = + List.of(new AgentMetadata("agentB", ImmutableList.of("missing"), "outputB")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + + assertThrows( + IllegalStateException.class, + () -> DependencyGraphSearch.search(graph, List.of(), "outputB")); + } + + @Test + void goalOrientedPlanner_producesCorrectExecutionPath() { + // Horoscope example: person + sign → horoscope → writeup + SimpleTestAgent personExtractor = new SimpleTestAgent("personExtractor"); + SimpleTestAgent signExtractor = new SimpleTestAgent("signExtractor"); + SimpleTestAgent horoscopeGenerator = new SimpleTestAgent("horoscopeGenerator"); + SimpleTestAgent writer = new SimpleTestAgent("writer"); + + List metadata = + List.of( + new AgentMetadata("personExtractor", ImmutableList.of("prompt"), "person"), + new AgentMetadata("signExtractor", ImmutableList.of("prompt"), "sign"), + new AgentMetadata( + "horoscopeGenerator", ImmutableList.of("person", "sign"), "horoscope"), + new AgentMetadata("writer", ImmutableList.of("person", "horoscope"), "writeup")); + + GoalOrientedPlanner planner = new GoalOrientedPlanner("writeup", metadata); + ImmutableList agents = + ImmutableList.of(personExtractor, signExtractor, horoscopeGenerator, writer); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("prompt", "My name is Mario and my zodiac sign is pisces"); + + PlanningContext context = createPlanningContext(agents, state); + planner.init(context); + + // Collect the full execution path + List executionOrder = new ArrayList<>(); + PlannerAction action = planner.firstAction(context).blockingGet(); + while (action instanceof PlannerAction.RunAgents runAgents) { + for (BaseAgent agent : runAgents.agents()) { + executionOrder.add(agent.name()); + } + action = planner.nextAction(context).blockingGet(); + } + + assertThat(executionOrder) + .containsExactly("personExtractor", "signExtractor", "horoscopeGenerator", "writer"); + // horoscopeGenerator must come after both extractors + assertThat(executionOrder.indexOf("horoscopeGenerator")) + .isGreaterThan(executionOrder.indexOf("personExtractor")); + assertThat(executionOrder.indexOf("horoscopeGenerator")) + .isGreaterThan(executionOrder.indexOf("signExtractor")); + // writer must come after horoscopeGenerator + assertThat(executionOrder.indexOf("writer")) + .isGreaterThan(executionOrder.indexOf("horoscopeGenerator")); + } + + @Test + void goalOrientedPlanner_returnsDoneWhenPathComplete() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + List metadata = + List.of(new AgentMetadata("agentA", ImmutableList.of(), "outputA")); + + GoalOrientedPlanner planner = new GoalOrientedPlanner("outputA", metadata); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(second).isInstanceOf(PlannerAction.Done.class); + } + + private static PlanningContext createPlanningContext( + ImmutableList agents, ConcurrentHashMap state) { + // Create a minimal InvocationContext for testing + com.google.adk.sessions.InMemorySessionService sessionService = + new com.google.adk.sessions.InMemorySessionService(); + com.google.adk.sessions.Session session = + sessionService.createSession("test-app", "test-user").blockingGet(); + session.state().putAll(state); + + BaseAgent rootAgent = agents.isEmpty() ? new SimpleTestAgent("root") : agents.get(0); + InvocationContext invocationContext = + InvocationContext.builder() + .sessionService(sessionService) + .invocationId("test-invocation") + .agent(rootAgent) + .session(session) + .build(); + + return new PlanningContext(invocationContext, agents); + } +} diff --git a/contrib/planners/src/test/java/com/google/adk/planner/p2p/P2PPlannerTest.java b/contrib/planners/src/test/java/com/google/adk/planner/p2p/P2PPlannerTest.java new file mode 100644 index 000000000..8640f5dbc --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/p2p/P2PPlannerTest.java @@ -0,0 +1,243 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.planner.p2p; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.InvocationContext; +import com.google.adk.agents.PlannerAction; +import com.google.adk.agents.PlanningContext; +import com.google.adk.events.Event; +import com.google.adk.planner.goap.AgentMetadata; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.sessions.Session; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Flowable; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link P2PPlanner}. */ +class P2PPlannerTest { + + private static final class SimpleTestAgent extends BaseAgent { + SimpleTestAgent(String name) { + super(name, "test agent " + name, ImmutableList.of(), null, null); + } + + @Override + protected Flowable runAsyncImpl(InvocationContext ctx) { + return Flowable.empty(); + } + + @Override + protected Flowable runLiveImpl(InvocationContext ctx) { + return Flowable.empty(); + } + } + + @Test + void firstAction_activatesAgentsWithSatisfiedInputs() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + List metadata = + List.of(new AgentMetadata("agentA", ImmutableList.of("topic"), "findings")); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("topic", "quantum computing"); + + P2PPlanner planner = new P2PPlanner(metadata, 10); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA), state); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + PlannerAction.RunAgents runAgents = (PlannerAction.RunAgents) action; + assertThat(runAgents.agents()).hasSize(1); + assertThat(runAgents.agents().get(0).name()).isEqualTo("agentA"); + } + + @Test + void firstAction_activatesMultipleAgentsInParallel() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of("topic"), "findingsA"), + new AgentMetadata("agentB", ImmutableList.of("topic"), "findingsB")); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("topic", "quantum computing"); + + P2PPlanner planner = new P2PPlanner(metadata, 10); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA, agentB), state); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + PlannerAction.RunAgents runAgents = (PlannerAction.RunAgents) action; + assertThat(runAgents.agents()).hasSize(2); + } + + @Test + void firstAction_doesNotActivateAgentsWithUnsatisfiedInputs() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of("topic"), "findings"), + new AgentMetadata("agentB", ImmutableList.of("topic", "findings"), "hypothesis")); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("topic", "quantum computing"); + // "findings" is not yet available, so agentB should not activate + + P2PPlanner planner = new P2PPlanner(metadata, 10); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA, agentB), state); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + PlannerAction.RunAgents runAgents = (PlannerAction.RunAgents) action; + assertThat(runAgents.agents()).hasSize(1); + assertThat(runAgents.agents().get(0).name()).isEqualTo("agentA"); + } + + @Test + void nextAction_activatesNewAgentsWhenInputsBecomeAvailable() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of("topic"), "findings"), + new AgentMetadata("agentB", ImmutableList.of("topic", "findings"), "hypothesis")); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("topic", "quantum computing"); + + P2PPlanner planner = new P2PPlanner(metadata, 10); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA, agentB), state); + planner.init(context); + + // First action: only agentA can activate + planner.firstAction(context).blockingGet(); + + // Simulate agentA producing "findings" — must update the session state directly + context.state().put("findings", "some research findings"); + + // Next action: agentB should now be able to activate + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + PlannerAction.RunAgents runAgents = (PlannerAction.RunAgents) action; + assertThat(runAgents.agents()).hasSize(1); + assertThat(runAgents.agents().get(0).name()).isEqualTo("agentB"); + } + + @Test + void nextAction_terminatesOnExitCondition() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + List metadata = + List.of(new AgentMetadata("agentA", ImmutableList.of("topic"), "score")); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("topic", "quantum computing"); + + P2PPlanner planner = + new P2PPlanner( + metadata, + 10, + (s, count) -> { + Object score = s.get("score"); + return score instanceof Number && ((Number) score).doubleValue() >= 0.85; + }); + + PlanningContext context = createPlanningContext(ImmutableList.of(agentA), state); + planner.init(context); + + // First action: agentA activates + planner.firstAction(context).blockingGet(); + + // Simulate score being produced above threshold — must update session state directly + context.state().put("score", 0.9); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void nextAction_terminatesOnMaxInvocations() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + List metadata = + List.of(new AgentMetadata("agentA", ImmutableList.of("topic"), "output")); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("topic", "quantum computing"); + + P2PPlanner planner = new P2PPlanner(metadata, 1); // only 1 invocation allowed + + PlanningContext context = createPlanningContext(ImmutableList.of(agentA), state); + planner.init(context); + + // First action consumes the single allowed invocation + planner.firstAction(context).blockingGet(); + + // Next should be Done due to maxInvocations + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void nextAction_returnsDoneWhenNoAgentsCanActivate() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + List metadata = + List.of(new AgentMetadata("agentA", ImmutableList.of("missingInput"), "output")); + + ConcurrentHashMap state = new ConcurrentHashMap<>(); + + P2PPlanner planner = new P2PPlanner(metadata, 10); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA), state); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + private static PlanningContext createPlanningContext( + ImmutableList agents, ConcurrentHashMap state) { + InMemorySessionService sessionService = new InMemorySessionService(); + Session session = sessionService.createSession("test-app", "test-user").blockingGet(); + session.state().putAll(state); + + BaseAgent rootAgent = agents.isEmpty() ? new SimpleTestAgent("root") : agents.get(0); + InvocationContext invocationContext = + InvocationContext.builder() + .sessionService(sessionService) + .invocationId("test-invocation") + .agent(rootAgent) + .session(session) + .build(); + + return new PlanningContext(invocationContext, agents); + } +} diff --git a/tutorials/planner-agents/pom.xml b/tutorials/planner-agents/pom.xml new file mode 100644 index 000000000..04f007bfd --- /dev/null +++ b/tutorials/planner-agents/pom.xml @@ -0,0 +1,56 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-parent + 0.5.1-SNAPSHOT + ../../pom.xml + + + google-adk-tutorials-planner-agents + Agent Development Kit - Planner Agents Tutorial + Tutorial examples demonstrating GOAP, P2P, and Supervisor planner patterns. + + + + com.google.adk + google-adk + ${project.version} + + + com.google.adk + google-adk-planners + ${project.version} + + + com.google.adk + google-adk-dev + ${project.version} + + + com.google.genai + google-genai + + + org.slf4j + slf4j-simple + + + diff --git a/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/HoroscopeWriterExample.java b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/HoroscopeWriterExample.java new file mode 100644 index 000000000..53bef2d0e --- /dev/null +++ b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/HoroscopeWriterExample.java @@ -0,0 +1,143 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.tutorials.planner.goap; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.PlannerAgent; +import com.google.adk.planner.goap.AgentMetadata; +import com.google.adk.planner.goap.GoalOrientedPlanner; +import com.google.adk.tools.Annotations.Schema; +import com.google.adk.tools.FunctionTool; +import com.google.adk.web.AdkWebServer; +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.Map; + +/** + * GOAP Horoscope Writer Example. + * + *

Demonstrates Goal-Oriented Action Planning (GOAP) where agent execution order is automatically + * resolved from input/output dependency declarations. + * + *

Agent dependency graph: + * + *

+ *   personExtractor (prompt → person)
+ *   signExtractor   (prompt → sign)
+ *   horoscopeGenerator (person, sign → horoscope)
+ *   writer          (person, horoscope → writeup)
+ * 
+ * + *

The GOAP planner automatically resolves: personExtractor → signExtractor → horoscopeGenerator + * → writer + * + *

Usage: Set the GOOGLE_API_KEY environment variable and run with initial state: {@code + * {"prompt": "My name is Mario and my zodiac sign is pisces"}} + */ +public class HoroscopeWriterExample { + + // Tool methods that write to session state + public static Map savePerson( + @Schema(name = "person", description = "The extracted person's name") String person) { + return Map.of("person", person); + } + + public static Map saveSign( + @Schema(name = "sign", description = "The extracted zodiac sign") String sign) { + return Map.of("sign", sign); + } + + public static Map saveHoroscope( + @Schema(name = "horoscope", description = "The generated horoscope") String horoscope) { + return Map.of("horoscope", horoscope); + } + + public static Map saveWriteup( + @Schema(name = "writeup", description = "The final amusing horoscope writeup") + String writeup) { + return Map.of("writeup", writeup); + } + + public static final BaseAgent ROOT_AGENT = createAgent(); + + private static BaseAgent createAgent() { + LlmAgent personExtractor = + LlmAgent.builder() + .name("personExtractor") + .description("Extract person's name from the prompt") + .model("gemini-2.0-flash") + .instruction( + "Extract the person's name from the user's prompt and save it using the" + + " savePerson tool. The prompt is: {prompt}") + .tools(FunctionTool.create(HoroscopeWriterExample.class, "savePerson")) + .build(); + + LlmAgent signExtractor = + LlmAgent.builder() + .name("signExtractor") + .description("Extract zodiac sign from the prompt") + .model("gemini-2.0-flash") + .instruction( + "Extract the zodiac sign from the user's prompt and save it using the saveSign" + + " tool. The prompt is: {prompt}") + .tools(FunctionTool.create(HoroscopeWriterExample.class, "saveSign")) + .build(); + + LlmAgent horoscopeGenerator = + LlmAgent.builder() + .name("horoscopeGenerator") + .description("Generate a horoscope based on person and sign") + .model("gemini-2.0-flash") + .instruction( + "Generate a horoscope for person={person} with zodiac sign={sign}. Save it using" + + " the saveHoroscope tool.") + .tools(FunctionTool.create(HoroscopeWriterExample.class, "saveHoroscope")) + .build(); + + LlmAgent writer = + LlmAgent.builder() + .name("writer") + .description("Write amusing horoscope writeup") + .model("gemini-2.0-flash") + .instruction( + "Write an amusing horoscope writeup for person={person} based on their" + + " horoscope={horoscope}. Make it fun and entertaining. Save using the" + + " saveWriteup tool.") + .tools(FunctionTool.create(HoroscopeWriterExample.class, "saveWriteup")) + .build(); + + List metadata = + List.of( + new AgentMetadata("personExtractor", ImmutableList.of("prompt"), "person"), + new AgentMetadata("signExtractor", ImmutableList.of("prompt"), "sign"), + new AgentMetadata( + "horoscopeGenerator", ImmutableList.of("person", "sign"), "horoscope"), + new AgentMetadata("writer", ImmutableList.of("person", "horoscope"), "writeup")); + + return PlannerAgent.builder() + .name("horoscopeWriter") + .description("Generates personalized amusing horoscopes using GOAP planning") + .subAgents(personExtractor, signExtractor, horoscopeGenerator, writer) + .planner(new GoalOrientedPlanner("writeup", metadata)) + .build(); + } + + public static void main(String[] args) { + AdkWebServer.start(ROOT_AGENT); + } +} diff --git a/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/StoryWriterExample.java b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/StoryWriterExample.java new file mode 100644 index 000000000..61932f34b --- /dev/null +++ b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/goap/StoryWriterExample.java @@ -0,0 +1,156 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.tutorials.planner.goap; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.LoopAgent; +import com.google.adk.agents.PlannerAgent; +import com.google.adk.planner.goap.AgentMetadata; +import com.google.adk.planner.goap.GoalOrientedPlanner; +import com.google.adk.tools.Annotations.Schema; +import com.google.adk.tools.FunctionTool; +import com.google.adk.web.AdkWebServer; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Maybe; +import java.util.List; +import java.util.Map; + +/** + * GOAP Story Writer Example with embedded feedback loop. + * + *

Demonstrates a GOAP-planned pipeline with an inner loop: story generation → style editing + + * scoring (loops until quality threshold) → audience adaptation. + * + *

Agent dependency graph: + * + *

+ *   storyGenerator   (topic → story)
+ *   styleReviewLoop  (story, style → styledStory)    [inner LoopAgent]
+ *   audienceEditor   (styledStory, audience → finalStory)
+ * 
+ * + *

Usage: Set GOOGLE_API_KEY and run with initial state: {@code {"topic": "dragons and a wizard", + * "style": "fantasy", "audience": "young adults"}} + */ +public class StoryWriterExample { + + public static Map saveStory( + @Schema(name = "story", description = "The generated story") String story) { + return Map.of("story", story); + } + + public static Map saveStyledStory( + @Schema(name = "styledStory", description = "The style-edited story") String styledStory) { + return Map.of("styledStory", styledStory); + } + + public static Map saveScore( + @Schema(name = "score", description = "Style quality score 0.0-1.0") String score) { + return Map.of("score", score); + } + + public static Map saveFinalStory( + @Schema(name = "finalStory", description = "The audience-adapted final story") + String finalStory) { + return Map.of("finalStory", finalStory); + } + + public static final BaseAgent ROOT_AGENT = createAgent(); + + private static BaseAgent createAgent() { + LlmAgent storyGenerator = + LlmAgent.builder() + .name("storyGenerator") + .description("Generate initial story from topic") + .model("gemini-2.0-flash") + .instruction("Write a short story about {topic}. Save it using the saveStory tool.") + .tools(FunctionTool.create(StoryWriterExample.class, "saveStory")) + .build(); + + LlmAgent styleEditor = + LlmAgent.builder() + .name("styleEditor") + .description("Edit story for target style") + .model("gemini-2.0-flash") + .instruction( + "Edit the story at {story} to match the {style} style. Save the result using the" + + " saveStyledStory tool.") + .tools(FunctionTool.create(StoryWriterExample.class, "saveStyledStory")) + .build(); + + LlmAgent styleScorer = + LlmAgent.builder() + .name("styleScorer") + .description("Score story style quality 0.0-1.0") + .model("gemini-2.0-flash") + .instruction( + "Score how well the story at {styledStory} matches the target style={style}." + + " Provide a score from 0.0 to 1.0. Save the score using the saveScore tool.") + .tools(FunctionTool.create(StoryWriterExample.class, "saveScore")) + .build(); + + // Style review loop: alternates between editing and scoring until quality >= 0.8 + LoopAgent styleReviewLoop = + LoopAgent.builder() + .name("styleReviewLoop") + .description("Iteratively edit and score story style until quality threshold is met") + .subAgents(styleEditor, styleScorer) + .maxIterations(5) + .afterAgentCallback( + ctx -> { + Object scoreObj = ctx.state().get("score"); + if (scoreObj != null) { + double score = Double.parseDouble(scoreObj.toString()); + if (score >= 0.8) { + ctx.eventActions().setEscalate(true); + } + } + return Maybe.empty(); + }) + .build(); + + LlmAgent audienceEditor = + LlmAgent.builder() + .name("audienceEditor") + .description("Adapt story for target audience") + .model("gemini-2.0-flash") + .instruction( + "Edit the story at {styledStory} to be appropriate for the target" + + " audience={audience}. Save the result using the saveFinalStory tool.") + .tools(FunctionTool.create(StoryWriterExample.class, "saveFinalStory")) + .build(); + + List metadata = + List.of( + new AgentMetadata("storyGenerator", ImmutableList.of("topic"), "story"), + new AgentMetadata("styleReviewLoop", ImmutableList.of("story", "style"), "styledStory"), + new AgentMetadata( + "audienceEditor", ImmutableList.of("styledStory", "audience"), "finalStory")); + + return PlannerAgent.builder() + .name("storyWriter") + .description("Writes styled stories for target audiences using GOAP planning") + .subAgents(storyGenerator, styleReviewLoop, audienceEditor) + .planner(new GoalOrientedPlanner("finalStory", metadata)) + .build(); + } + + public static void main(String[] args) { + AdkWebServer.start(ROOT_AGENT); + } +} diff --git a/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/p2p/ResearchCollaborationExample.java b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/p2p/ResearchCollaborationExample.java new file mode 100644 index 000000000..da174fa2c --- /dev/null +++ b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/p2p/ResearchCollaborationExample.java @@ -0,0 +1,156 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.tutorials.planner.p2p; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.PlannerAgent; +import com.google.adk.planner.goap.AgentMetadata; +import com.google.adk.planner.p2p.P2PPlanner; +import com.google.adk.tools.Annotations.Schema; +import com.google.adk.tools.FunctionTool; +import com.google.adk.web.AdkWebServer; +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.Map; + +/** + * P2P Research Collaboration Example. + * + *

Demonstrates Peer-to-Peer planning where agents activate dynamically as their input + * dependencies become available. Supports iterative refinement — when a critic updates their + * critique, the hypothesis agent re-activates. + * + *

Agent activation flow: + * + *

+ *   Wave 1: literatureAgent starts (has: topic)
+ *   Wave 2: hypothesisAgent starts (has: topic + researchFindings)
+ *   Wave 3: criticAgent starts (has: topic + hypothesis)
+ *   Wave 4: scorerAgent starts (has: topic + hypothesis + critique)
+ *     → If score < 0.85: hypothesisAgent re-activates (input "critique" changed)
+ *     → Loop continues until score >= 0.85 or maxInvocations
+ * 
+ * + *

Usage: Set GOOGLE_API_KEY and run with initial state: {@code {"topic": "quantum entanglement + * effects on information transfer"}} + */ +public class ResearchCollaborationExample { + + public static Map saveFindings( + @Schema(name = "researchFindings", description = "Research findings from literature review") + String researchFindings) { + return Map.of("researchFindings", researchFindings); + } + + public static Map saveHypothesis( + @Schema(name = "hypothesis", description = "The formulated research hypothesis") + String hypothesis) { + return Map.of("hypothesis", hypothesis); + } + + public static Map saveCritique( + @Schema(name = "critique", description = "Critical evaluation of the hypothesis") + String critique) { + return Map.of("critique", critique); + } + + public static Map saveScore( + @Schema(name = "score", description = "Hypothesis quality score 0.0-1.0") String score) { + return Map.of("score", score); + } + + public static final BaseAgent ROOT_AGENT = createAgent(); + + private static BaseAgent createAgent() { + LlmAgent literatureAgent = + LlmAgent.builder() + .name("literatureAgent") + .description("Search scientific literature on a topic") + .model("gemini-2.0-flash") + .instruction( + "Research the topic={topic}. Provide a comprehensive literature review. Save the" + + " findings using the saveFindings tool.") + .tools(FunctionTool.create(ResearchCollaborationExample.class, "saveFindings")) + .build(); + + LlmAgent hypothesisAgent = + LlmAgent.builder() + .name("hypothesisAgent") + .description("Formulate hypothesis based on research") + .model("gemini-2.0-flash") + .instruction( + "Based on topic={topic} and findings={researchFindings}, formulate a testable" + + " research hypothesis. If a critique is available at {critique}, improve your" + + " hypothesis to address the critique. Save using the saveHypothesis tool.") + .tools(FunctionTool.create(ResearchCollaborationExample.class, "saveHypothesis")) + .build(); + + LlmAgent criticAgent = + LlmAgent.builder() + .name("criticAgent") + .description("Critically evaluate a hypothesis") + .model("gemini-2.0-flash") + .instruction( + "Evaluate the hypothesis={hypothesis} for topic={topic}. Identify strengths," + + " weaknesses, and suggestions for improvement. Save using the saveCritique" + + " tool.") + .tools(FunctionTool.create(ResearchCollaborationExample.class, "saveCritique")) + .build(); + + LlmAgent scorerAgent = + LlmAgent.builder() + .name("scorerAgent") + .description("Score hypothesis quality 0.0-1.0") + .model("gemini-2.0-flash") + .instruction( + "Score the hypothesis={hypothesis} considering the critique={critique} for" + + " topic={topic}. Rate from 0.0 (poor) to 1.0 (excellent). Save a numeric" + + " score using the saveScore tool.") + .tools(FunctionTool.create(ResearchCollaborationExample.class, "saveScore")) + .build(); + + List metadata = + List.of( + new AgentMetadata("literatureAgent", ImmutableList.of("topic"), "researchFindings"), + new AgentMetadata( + "hypothesisAgent", ImmutableList.of("topic", "researchFindings"), "hypothesis"), + new AgentMetadata("criticAgent", ImmutableList.of("topic", "hypothesis"), "critique"), + new AgentMetadata( + "scorerAgent", ImmutableList.of("topic", "hypothesis", "critique"), "score")); + + // Exit when score >= 0.85 or after 10 total invocations + return PlannerAgent.builder() + .name("researcher") + .description("Collaborative research with iterative hypothesis refinement") + .subAgents(literatureAgent, hypothesisAgent, criticAgent, scorerAgent) + .planner( + new P2PPlanner( + metadata, + 10, + (state, count) -> { + Object scoreVal = state.get("score"); + return scoreVal instanceof Number && ((Number) scoreVal).doubleValue() >= 0.85; + })) + .maxIterations(20) + .build(); + } + + public static void main(String[] args) { + AdkWebServer.start(ROOT_AGENT); + } +} diff --git a/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/supervisor/SupervisorExample.java b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/supervisor/SupervisorExample.java new file mode 100644 index 000000000..2075d64cf --- /dev/null +++ b/tutorials/planner-agents/src/main/java/com/google/adk/tutorials/planner/supervisor/SupervisorExample.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.tutorials.planner.supervisor; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.PlannerAgent; +import com.google.adk.models.Gemini; +import com.google.adk.planner.SupervisorPlanner; +import com.google.adk.web.AdkWebServer; + +/** + * Supervisor Planner Example. + * + *

Demonstrates LLM-driven agent coordination where the supervisor LLM dynamically decides which + * sub-agent to run next based on the current state and conversation history. + * + *

The supervisor LLM evaluates available agents and decides the sequence: it might run research + * first, then writing, then review — or might skip steps if the task is simple. + */ +public class SupervisorExample { + + public static final BaseAgent ROOT_AGENT = createAgent(); + + private static BaseAgent createAgent() { + LlmAgent researchAgent = + LlmAgent.builder() + .name("researchAgent") + .description("Researches a topic and provides comprehensive findings") + .model("gemini-2.0-flash") + .instruction("Research the user's topic thoroughly. Provide detailed findings.") + .build(); + + LlmAgent writerAgent = + LlmAgent.builder() + .name("writerAgent") + .description("Writes clear, well-structured content based on research") + .model("gemini-2.0-flash") + .instruction( + "Write clear, well-structured content based on the research findings. Create" + + " engaging prose.") + .build(); + + LlmAgent reviewerAgent = + LlmAgent.builder() + .name("reviewerAgent") + .description("Reviews content for accuracy, clarity, and completeness") + .model("gemini-2.0-flash") + .instruction( + "Review the written content for accuracy, clarity, and completeness. Suggest" + + " improvements if needed.") + .build(); + + return PlannerAgent.builder() + .name("supervisorAgent") + .description("LLM-supervised task coordination") + .subAgents(researchAgent, writerAgent, reviewerAgent) + .planner( + new SupervisorPlanner( + Gemini.builder().modelName("gemini-2.0-flash").build(), + "You are a project manager coordinating research, writing, and review agents." + + " Decide which agent to run next based on what has been accomplished so far." + + " Run researchAgent first, then writerAgent, then reviewerAgent. After all" + + " three have run, respond with DONE.")) + .maxIterations(10) + .build(); + } + + public static void main(String[] args) { + AdkWebServer.start(ROOT_AGENT); + } +} From dc114e43cb865a185f0dd9f213bb25fb505ec05c Mon Sep 17 00:00:00 2001 From: ddobrin Date: Wed, 4 Mar 2026 08:51:42 -0500 Subject: [PATCH 3/3] main planner classes --- .../java/com/google/adk/agents/Planner.java | 54 +++ .../com/google/adk/agents/PlannerAction.java | 54 +++ .../com/google/adk/agents/PlannerAgent.java | 227 ++++++++++++ .../google/adk/agents/PlanningContext.java | 86 +++++ .../google/adk/agents/PlannerAgentTest.java | 325 ++++++++++++++++++ 5 files changed, 746 insertions(+) create mode 100644 core/src/main/java/com/google/adk/agents/Planner.java create mode 100644 core/src/main/java/com/google/adk/agents/PlannerAction.java create mode 100644 core/src/main/java/com/google/adk/agents/PlannerAgent.java create mode 100644 core/src/main/java/com/google/adk/agents/PlanningContext.java create mode 100644 core/src/test/java/com/google/adk/agents/PlannerAgentTest.java diff --git a/core/src/main/java/com/google/adk/agents/Planner.java b/core/src/main/java/com/google/adk/agents/Planner.java new file mode 100644 index 000000000..cc6e741a2 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/Planner.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.agents; + +import io.reactivex.rxjava3.core.Single; + +/** + * Strategy interface for planning which sub-agent(s) to execute next. + * + *

A {@code Planner} is used by {@link PlannerAgent} to dynamically determine execution order at + * runtime. The planning loop works as follows: + * + *

    + *
  1. {@link #init} is called once before the loop starts + *
  2. {@link #firstAction} returns the first action to execute + *
  3. The selected agent(s) execute, producing events and updating session state + *
  4. {@link #nextAction} is called with updated context to decide what to do next + *
  5. Steps 3-4 repeat until {@link PlannerAction.Done} or max iterations + *
+ * + *

Returns {@link Single}{@code } to support both synchronous planners (wrap in + * {@code Single.just()}) and asynchronous planners that call an LLM. + */ +public interface Planner { + + /** + * Initialize the planner with context and available agents. Called once before the planning loop + * starts. + * + *

Default implementation is a no-op. Override to perform setup like building dependency + * graphs. + */ + default void init(PlanningContext context) {} + + /** Select the first action to execute. */ + Single firstAction(PlanningContext context); + + /** Select the next action based on updated state and events. */ + Single nextAction(PlanningContext context); +} diff --git a/core/src/main/java/com/google/adk/agents/PlannerAction.java b/core/src/main/java/com/google/adk/agents/PlannerAction.java new file mode 100644 index 000000000..f05dfaf1e --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/PlannerAction.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.agents; + +import com.google.common.collect.ImmutableList; + +/** + * Represents the next action a {@link Planner} wants the {@link PlannerAgent} to take. + * + *

This is a sealed interface with four variants: + * + *

    + *
  • {@link RunAgents} — execute one or more sub-agents (multiple agents run in parallel) + *
  • {@link Done} — planning is complete, no result to emit + *
  • {@link DoneWithResult} — planning is complete with a final text result + *
  • {@link NoOp} — skip this iteration (no-op), then ask the planner for the next action + *
+ */ +public sealed interface PlannerAction + permits PlannerAction.RunAgents, + PlannerAction.Done, + PlannerAction.DoneWithResult, + PlannerAction.NoOp { + + /** Run the specified sub-agent(s). Multiple agents are run in parallel. */ + record RunAgents(ImmutableList agents) implements PlannerAction { + public RunAgents(BaseAgent singleAgent) { + this(ImmutableList.of(singleAgent)); + } + } + + /** Plan is complete, no result to emit. */ + record Done() implements PlannerAction {} + + /** Plan is complete with a final text result. */ + record DoneWithResult(String result) implements PlannerAction {} + + /** Skip this iteration (no-op). */ + record NoOp() implements PlannerAction {} +} diff --git a/core/src/main/java/com/google/adk/agents/PlannerAgent.java b/core/src/main/java/com/google/adk/agents/PlannerAgent.java new file mode 100644 index 000000000..936d619de --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/PlannerAgent.java @@ -0,0 +1,227 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.agents; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Flowable; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An agent that delegates execution planning to a {@link Planner} strategy. + * + *

The {@code PlannerAgent} owns a set of sub-agents and a planner. At runtime, the planner + * inspects session state and decides which sub-agent(s) to run next. This enables dynamic, + * goal-oriented agent orchestration — the execution topology is determined at runtime rather than + * being fixed at build time. + * + *

The planning loop: + * + *

    + *
  1. Planner is initialized with context and available agents + *
  2. Planner returns what to do next via {@link PlannerAction} + *
  3. Selected sub-agent(s) execute, producing events + *
  4. Session state (world state) is updated from events + *
  5. Planner sees updated state and decides the next action + *
  6. Repeat until {@link PlannerAction.Done} or maxIterations + *
+ * + *

Example usage with a custom planner: + * + *

{@code
+ * PlannerAgent agent = PlannerAgent.builder()
+ *     .name("myAgent")
+ *     .subAgents(agentA, agentB, agentC)
+ *     .planner(new GoalOrientedPlanner("finalOutput", metadata))
+ *     .maxIterations(20)
+ *     .build();
+ * }
+ */ +public class PlannerAgent extends BaseAgent { + private static final Logger logger = LoggerFactory.getLogger(PlannerAgent.class); + private static final int DEFAULT_MAX_ITERATIONS = 100; + + private final Planner planner; + private final int maxIterations; + + private PlannerAgent( + String name, + String description, + List subAgents, + Planner planner, + int maxIterations, + List beforeAgentCallback, + List afterAgentCallback) { + super(name, description, subAgents, beforeAgentCallback, afterAgentCallback); + this.planner = planner; + this.maxIterations = maxIterations; + } + + /** Returns the planner strategy used by this agent. */ + public Planner planner() { + return planner; + } + + /** Returns the maximum number of planning iterations. */ + public int maxIterations() { + return maxIterations; + } + + @Override + protected Flowable runAsyncImpl(InvocationContext invocationContext) { + List agents = subAgents(); + if (agents == null || agents.isEmpty()) { + return Flowable.empty(); + } + + ImmutableList available = + agents.stream().map(a -> (BaseAgent) a).collect(toImmutableList()); + PlanningContext planningContext = new PlanningContext(invocationContext, available); + + planner.init(planningContext); + + AtomicInteger iteration = new AtomicInteger(0); + + return planner + .firstAction(planningContext) + .flatMapPublisher( + firstAction -> + executeActionAndContinue( + firstAction, planningContext, invocationContext, iteration)); + } + + private Flowable executeActionAndContinue( + PlannerAction action, + PlanningContext planningContext, + InvocationContext invocationContext, + AtomicInteger iteration) { + + int current = iteration.getAndIncrement(); + if (current >= maxIterations) { + logger.info("PlannerAgent '{}' reached maxIterations={}", name(), maxIterations); + return Flowable.empty(); + } + + if (action instanceof PlannerAction.Done) { + return Flowable.empty(); + } + + if (action instanceof PlannerAction.DoneWithResult doneWithResult) { + Event resultEvent = + Event.builder() + .id(Event.generateEventId()) + .invocationId(invocationContext.invocationId()) + .author(name()) + .branch(invocationContext.branch()) + .content(Content.fromParts(Part.fromText(doneWithResult.result()))) + .actions(EventActions.builder().build()) + .build(); + return Flowable.just(resultEvent); + } + + if (action instanceof PlannerAction.NoOp) { + return Flowable.defer( + () -> + planner + .nextAction(planningContext) + .flatMapPublisher( + nextAction -> + executeActionAndContinue( + nextAction, planningContext, invocationContext, iteration))); + } + + if (action instanceof PlannerAction.RunAgents runAgents) { + Flowable agentEvents; + if (runAgents.agents().size() == 1) { + agentEvents = runAgents.agents().get(0).runAsync(invocationContext); + } else { + agentEvents = + Flowable.merge( + runAgents.agents().stream() + .map(agent -> agent.runAsync(invocationContext)) + .collect(toImmutableList())); + } + + return agentEvents.concatWith( + Flowable.defer( + () -> + planner + .nextAction(planningContext) + .flatMapPublisher( + nextAction -> + executeActionAndContinue( + nextAction, planningContext, invocationContext, iteration)))); + } + + // Unreachable for sealed interface, but required by compiler + return Flowable.empty(); + } + + @Override + protected Flowable runLiveImpl(InvocationContext invocationContext) { + return Flowable.error( + new UnsupportedOperationException("runLive is not defined for PlannerAgent yet.")); + } + + /** Returns a new {@link Builder} for creating {@link PlannerAgent} instances. */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link PlannerAgent}. */ + public static class Builder extends BaseAgent.Builder { + private Planner planner; + private int maxIterations = DEFAULT_MAX_ITERATIONS; + + @CanIgnoreReturnValue + public Builder planner(Planner planner) { + this.planner = planner; + return this; + } + + @CanIgnoreReturnValue + public Builder maxIterations(int maxIterations) { + this.maxIterations = maxIterations; + return this; + } + + @Override + public PlannerAgent build() { + if (planner == null) { + throw new IllegalStateException( + "PlannerAgent requires a Planner. Call .planner(...) on the builder."); + } + return new PlannerAgent( + name, + description, + subAgents, + planner, + maxIterations, + beforeAgentCallback, + afterAgentCallback); + } + } +} diff --git a/core/src/main/java/com/google/adk/agents/PlanningContext.java b/core/src/main/java/com/google/adk/agents/PlanningContext.java new file mode 100644 index 000000000..9ab22d83e --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/PlanningContext.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.agents; + +import com.google.adk.events.Event; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Content; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentMap; + +/** + * Context provided to a {@link Planner} during the planning loop. + * + *

Wraps an {@link InvocationContext} to expose the session state (world state), events, and + * available sub-agents. Planners use this to inspect the current state and decide which agent(s) to + * run next. + */ +public class PlanningContext { + + private final InvocationContext invocationContext; + private final ImmutableList availableAgents; + + public PlanningContext( + InvocationContext invocationContext, ImmutableList availableAgents) { + this.invocationContext = invocationContext; + this.availableAgents = availableAgents; + } + + /** Returns the session state — the shared "world state" that agents read and write. */ + public ConcurrentMap state() { + return invocationContext.session().state(); + } + + /** Returns all events in the current session. */ + public List events() { + return invocationContext.session().events(); + } + + /** Returns the sub-agents available for the planner to select from. */ + public ImmutableList availableAgents() { + return availableAgents; + } + + /** Returns the user content that initiated this invocation, if any. */ + public Optional userContent() { + return invocationContext.userContent(); + } + + /** + * Finds an available agent by name. + * + * @throws IllegalArgumentException if no agent with the given name is found. + */ + public BaseAgent findAgent(String name) { + return availableAgents.stream() + .filter(agent -> agent.name().equals(name)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "No available agent with name: " + + name + + ". Available: " + + availableAgents.stream().map(BaseAgent::name).toList())); + } + + /** Returns the full {@link InvocationContext} for advanced use cases. */ + public InvocationContext invocationContext() { + return invocationContext; + } +} diff --git a/core/src/test/java/com/google/adk/agents/PlannerAgentTest.java b/core/src/test/java/com/google/adk/agents/PlannerAgentTest.java new file mode 100644 index 000000000..bc0166d0e --- /dev/null +++ b/core/src/test/java/com/google/adk/agents/PlannerAgentTest.java @@ -0,0 +1,325 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.agents; + +import static com.google.adk.testing.TestUtils.createEvent; +import static com.google.adk.testing.TestUtils.createInvocationContext; +import static com.google.adk.testing.TestUtils.createSubAgent; +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.adk.testing.TestBaseAgent; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link PlannerAgent}. */ +@RunWith(JUnit4.class) +public final class PlannerAgentTest { + + @Test + public void runAsync_withDone_stopsImmediately() { + TestBaseAgent subAgent = createSubAgent("sub", createEvent("e1")); + Planner donePlanner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + return Single.just(new PlannerAction.Done()); + } + + @Override + public Single nextAction(PlanningContext context) { + return Single.just(new PlannerAction.Done()); + } + }; + + PlannerAgent agent = + PlannerAgent.builder().name("planner").subAgents(subAgent).planner(donePlanner).build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).isEmpty(); + } + + @Test + public void runAsync_withDoneWithResult_emitsResultEvent() { + TestBaseAgent subAgent = createSubAgent("sub"); + Planner resultPlanner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + return Single.just(new PlannerAction.DoneWithResult("final answer")); + } + + @Override + public Single nextAction(PlanningContext context) { + return Single.just(new PlannerAction.Done()); + } + }; + + PlannerAgent agent = + PlannerAgent.builder().name("planner").subAgents(subAgent).planner(resultPlanner).build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).hasSize(1); + assertThat(events.get(0).content().get().text()).isEqualTo("final answer"); + } + + @Test + public void runAsync_withNoOp_skipsAndContinues() { + Event event1 = createEvent("e1"); + TestBaseAgent subAgent = createSubAgent("sub", event1); + + AtomicInteger callCount = new AtomicInteger(0); + Planner noOpThenRunPlanner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + return Single.just(new PlannerAction.NoOp()); + } + + @Override + public Single nextAction(PlanningContext context) { + int count = callCount.incrementAndGet(); + if (count == 1) { + return Single.just(new PlannerAction.RunAgents(context.findAgent("sub"))); + } + return Single.just(new PlannerAction.Done()); + } + }; + + PlannerAgent agent = + PlannerAgent.builder() + .name("planner") + .subAgents(subAgent) + .planner(noOpThenRunPlanner) + .build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).containsExactly(event1); + } + + @Test + public void runAsync_withMaxIterations_stopsAtLimit() { + TestBaseAgent subAgent = createSubAgent("sub", () -> Flowable.just(createEvent("e"))); + + Planner alwaysRunPlanner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + return Single.just(new PlannerAction.RunAgents(context.findAgent("sub"))); + } + + @Override + public Single nextAction(PlanningContext context) { + return Single.just(new PlannerAction.RunAgents(context.findAgent("sub"))); + } + }; + + PlannerAgent agent = + PlannerAgent.builder() + .name("planner") + .subAgents(subAgent) + .planner(alwaysRunPlanner) + .maxIterations(3) + .build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + // 3 iterations: first + 2 next calls, each producing 1 event + assertThat(events).hasSize(3); + } + + @Test + public void runAsync_sequentialPlannerPattern() { + Event event1 = createEvent("e1"); + Event event2 = createEvent("e2"); + Event event3 = createEvent("e3"); + TestBaseAgent agentA = createSubAgent("agentA", event1); + TestBaseAgent agentB = createSubAgent("agentB", event2); + TestBaseAgent agentC = createSubAgent("agentC", event3); + + AtomicInteger cursor = new AtomicInteger(0); + ImmutableList order = ImmutableList.of("agentA", "agentB", "agentC"); + Planner seqPlanner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + cursor.set(0); + return selectNext(context); + } + + @Override + public Single nextAction(PlanningContext context) { + return selectNext(context); + } + + private Single selectNext(PlanningContext context) { + int idx = cursor.getAndIncrement(); + if (idx >= order.size()) { + return Single.just(new PlannerAction.Done()); + } + return Single.just(new PlannerAction.RunAgents(context.findAgent(order.get(idx)))); + } + }; + + PlannerAgent agent = + PlannerAgent.builder() + .name("planner") + .subAgents(agentA, agentB, agentC) + .planner(seqPlanner) + .build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).containsExactly(event1, event2, event3).inOrder(); + } + + @Test + public void runAsync_withParallelRunAgents_runsMultipleAgents() { + Event event1 = createEvent("e1"); + Event event2 = createEvent("e2"); + TestBaseAgent agentA = createSubAgent("agentA", event1); + TestBaseAgent agentB = createSubAgent("agentB", event2); + + Planner parallelPlanner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + return Single.just(new PlannerAction.RunAgents(context.availableAgents())); + } + + @Override + public Single nextAction(PlanningContext context) { + return Single.just(new PlannerAction.Done()); + } + }; + + PlannerAgent agent = + PlannerAgent.builder() + .name("planner") + .subAgents(agentA, agentB) + .planner(parallelPlanner) + .build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).containsExactly(event1, event2); + } + + @Test + public void runAsync_withEmptySubAgents_returnsEmpty() { + Planner planner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + return Single.just(new PlannerAction.Done()); + } + + @Override + public Single nextAction(PlanningContext context) { + return Single.just(new PlannerAction.Done()); + } + }; + + PlannerAgent agent = + PlannerAgent.builder() + .name("planner") + .subAgents(ImmutableList.of()) + .planner(planner) + .build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).isEmpty(); + } + + @Test(expected = IllegalStateException.class) + public void builder_withoutPlanner_throwsIllegalState() { + TestBaseAgent subAgent = createSubAgent("sub"); + PlannerAgent.builder().name("planner").subAgents(subAgent).build(); + } + + @Test + public void runAsync_stateIsSharedAcrossAgents() { + // Agent A writes to state, Agent B reads from state + Event eventA = + createEvent("eA").toBuilder() + .actions( + EventActions.builder() + .stateDelta( + new java.util.concurrent.ConcurrentHashMap<>( + java.util.Map.of("key1", "value1"))) + .build()) + .build(); + + TestBaseAgent agentA = createSubAgent("agentA", eventA); + TestBaseAgent agentB = createSubAgent("agentB", createEvent("eB")); + + AtomicInteger cursor = new AtomicInteger(0); + Planner seqPlanner = + new Planner() { + @Override + public Single firstAction(PlanningContext context) { + cursor.set(0); + return nextAction(context); + } + + @Override + public Single nextAction(PlanningContext context) { + int idx = cursor.getAndIncrement(); + if (idx == 0) { + return Single.just(new PlannerAction.RunAgents(context.findAgent("agentA"))); + } + if (idx == 1) { + return Single.just(new PlannerAction.RunAgents(context.findAgent("agentB"))); + } + return Single.just(new PlannerAction.Done()); + } + }; + + PlannerAgent agent = + PlannerAgent.builder() + .name("planner") + .subAgents(agentA, agentB) + .planner(seqPlanner) + .build(); + + InvocationContext ctx = createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + // Both events should be emitted + assertThat(events).hasSize(2); + // State delta from agentA's event should be present + assertThat(events.get(0).actions().stateDelta()).containsEntry("key1", "value1"); + } +}