From 076e903040b35c8f8bcf5de72b635e025401fb6f Mon Sep 17 00:00:00 2001 From: ddobrin Date: Mon, 6 Apr 2026 21:13:32 -0400 Subject: [PATCH] Agentic Planning for ADK --- contrib/planners/README.md | 941 +++++++++++++++++ contrib/planners/pom.xml | 72 ++ .../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 ++ .../com/google/adk/planner/LoopPlanner.java | 90 ++ .../google/adk/planner/ParallelPlanner.java | 39 + .../google/adk/planner/SequentialPlanner.java | 56 + .../google/adk/planner/SupervisorPlanner.java | 208 ++++ .../adk/planner/goap/AStarSearchStrategy.java | 168 +++ .../adk/planner/goap/AgentMetadata.java | 32 + .../planner/goap/DependencyGraphSearch.java | 190 ++++ .../adk/planner/goap/DfsSearchStrategy.java | 38 + .../adk/planner/goap/GoalOrientedPlanner.java | 216 ++++ .../planner/goap/GoalOrientedSearchGraph.java | 69 ++ .../google/adk/planner/goap/ReplanPolicy.java | 45 + .../adk/planner/goap/SearchStrategy.java | 47 + .../adk/planner/p2p/AgentActivator.java | 70 ++ .../google/adk/planner/p2p/P2PPlanner.java | 173 ++++ .../google/adk/agents/PlannerAgentTest.java | 324 ++++++ .../google/adk/planner/LoopPlannerTest.java | 187 ++++ .../adk/planner/ParallelPlannerTest.java | 127 +++ .../adk/planner/SequentialPlannerTest.java | 156 +++ .../adk/planner/SupervisorPlannerTest.java | 233 +++++ .../planner/goap/AStarSearchStrategyTest.java | 295 ++++++ .../goap/GoapLlmCouncilTopologyTest.java | 967 ++++++++++++++++++ .../adk/planner/goap/ReplanningTest.java | 615 +++++++++++ .../p2p/P2PLlmCouncilTopologyTest.java | 828 +++++++++++++++ pom.xml | 1 + 30 files changed, 6608 insertions(+) create mode 100644 contrib/planners/README.md create mode 100644 contrib/planners/pom.xml create mode 100644 contrib/planners/src/main/java/com/google/adk/agents/Planner.java create mode 100644 contrib/planners/src/main/java/com/google/adk/agents/PlannerAction.java create mode 100644 contrib/planners/src/main/java/com/google/adk/agents/PlannerAgent.java create mode 100644 contrib/planners/src/main/java/com/google/adk/agents/PlanningContext.java 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/AStarSearchStrategy.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/DfsSearchStrategy.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/goap/ReplanPolicy.java create mode 100644 contrib/planners/src/main/java/com/google/adk/planner/goap/SearchStrategy.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/agents/PlannerAgentTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/LoopPlannerTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/ParallelPlannerTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/SequentialPlannerTest.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/AStarSearchStrategyTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/goap/GoapLlmCouncilTopologyTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/goap/ReplanningTest.java create mode 100644 contrib/planners/src/test/java/com/google/adk/planner/p2p/P2PLlmCouncilTopologyTest.java diff --git a/contrib/planners/README.md b/contrib/planners/README.md new file mode 100644 index 000000000..8cb74cf03 --- /dev/null +++ b/contrib/planners/README.md @@ -0,0 +1,941 @@ +# ADK Planners (`google-adk-planners`) + +Pluggable planner implementations for the ADK `PlannerAgent`. This module provides six planning strategies for dynamically orchestrating sub-agent execution at runtime — from simple sequential dispatch to sophisticated goal-oriented planning with adaptive replanning. + +## Table of Contents + +1. [Overview & Quick Start](#1-overview--quick-start) +2. [Architecture](#2-architecture) +3. [Simple Planners](#3-simple-planners) +4. [Goal-Oriented Action Planning (GOAP)](#4-goal-oriented-action-planning-goap) +5. [Peer-to-Peer (P2P) Planner](#5-peer-to-peer-p2p-planner) +6. [Choosing a Planner](#6-choosing-a-planner) +7. [Advanced Topics](#7-advanced-topics) +8. [Testing](#8-testing) +9. [Package Reference](#9-package-reference) +10. [License](#10-license) + +--- + +## 1. Overview & Quick Start + +### Maven Dependency + +```xml + + com.google.adk + google-adk-planners + ${adk.version} + +``` + +### Quick Start + +```java +PlannerAgent agent = PlannerAgent.builder() + .name("pipeline") + .description("Runs agents in sequence") + .subAgents(agentA, agentB, agentC) + .planner(new SequentialPlanner()) + .build(); +``` + +The `PlannerAgent` delegates execution decisions to a `Planner` strategy. Swap the planner to change how agents are orchestrated — the sub-agents stay the same. + +### Planners at a Glance + +| Planner | Package | Execution Model | LLM Required | Primary Use Case | +|---------|---------|----------------|:---:|-----------------| +| `SequentialPlanner` | `planner` | One at a time, in order | No | Fixed pipelines, ETL steps | +| `ParallelPlanner` | `planner` | All at once | No | Independent fan-out tasks | +| `LoopPlanner` | `planner` | Cyclic, repeating | No | Review/revision cycles | +| `SupervisorPlanner` | `planner` | LLM selects next agent(s) | Yes | Open-ended task delegation | +| `GoalOrientedPlanner` | `planner.goap` | Dependency-resolved groups | No | Workflows with input/output contracts | +| `P2PPlanner` | `planner.p2p` | Reactive dynamic activation | No | Collaborative refinement loops | + +--- + +## 2. Architecture + +### Core Abstractions + +The module is built on four core types in `com.google.adk.agents`: + +``` +PlannerAgent ──owns──> Planner (strategy interface) + │ │ + │ └─returns─> PlannerAction (sealed: what to do next) + │ + └──creates──> PlanningContext (state + agents + events) +``` + +**`Planner`** — Strategy interface with a three-step lifecycle: +- `init(PlanningContext)` — called once before the loop starts (default no-op; override for setup like building dependency graphs) +- `firstAction(PlanningContext)` — returns the first action to execute +- `nextAction(PlanningContext)` — returns the next action after agents execute and state updates + +All methods return `Single`, supporting both synchronous planners (wrap in `Single.just()`) and asynchronous planners that call an LLM. + +**`PlannerAction`** — Sealed interface with four variants representing what the planner wants to happen next: + +```java +public sealed interface PlannerAction + permits RunAgents, Done, DoneWithResult, NoOp { + + record RunAgents(ImmutableList agents) implements PlannerAction {} + record Done() implements PlannerAction {} + record DoneWithResult(String result) implements PlannerAction {} + record NoOp() implements PlannerAction {} +} +``` + +**`PlanningContext`** — The planner's view of the world: +- `state()` — session state map (`Map`) shared across all agents +- `events()` — all events produced so far in the session +- `availableAgents()` — the sub-agents the planner can select from +- `userContent()` — the user message that initiated this invocation (if any) +- `findAgent(name)` — look up an agent by name (throws `IllegalArgumentException` if not found) + +**`PlannerAgent`** — A `BaseAgent` that orchestrates the planning loop. Built via `PlannerAgent.builder()` with a required `planner(...)` and optional `maxIterations(int)` (default: 100). + +### The Planning Loop + +``` +┌────────────────────────────────────────────────────────────────┐ +│ PlannerAgent.runAsyncImpl() │ +│ │ +│ planner.init(context) │ +│ │ │ +│ v │ +│ planner.firstAction(context) │ +│ │ │ +│ v │ +│ ┌───────────────────────────────────┐ │ +│ │ action instanceof ... │ │ +│ ├───────────────────────────────────┤ │ +│ │ Done → stop (empty) │ │ +│ │ DoneWithResult → emit text event │ │ +│ │ NoOp → skip to nextAction │──┐ │ +│ │ RunAgents → execute agent(s) │ │ │ +│ └───────────────────┬───────────────┘ │ │ +│ │ │ │ +│ v │ │ +│ planner.nextAction(context) ◄──┘ │ +│ │ │ +│ └── loop until Done or maxIterations ──┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Action semantics within the loop:** + +| Action | Behavior | +|--------|----------| +| `RunAgents` (1 agent) | Dispatches to `agent.runAsync(invocationContext)` | +| `RunAgents` (N agents) | Dispatches all in parallel via `Flowable.merge(...)` | +| `Done` | Emits nothing; loop terminates | +| `DoneWithResult` | Emits a single text `Event` with the result string | +| `NoOp` | Skips agent execution; immediately calls `planner.nextAction()` | + +### Reactive Execution Model + +The module uses RxJava 3 throughout: +- Planners return `Single` — a single async value +- Agent execution produces `Flowable` — a stream of events +- The loop chains actions via `concatWith(Flowable.defer(...))` for lazy sequential composition + +This means planners can be purely synchronous (e.g., `SequentialPlanner` uses `Single.just(...)`) or genuinely asynchronous (e.g., `SupervisorPlanner` makes an LLM call that returns a `Single`). + +--- + +## 3. Simple Planners + +Four ready-to-use planners for common orchestration patterns. All are in `com.google.adk.planner`. + +### SequentialPlanner + +Runs sub-agents one at a time in registration order. No configuration needed. + +```java +PlannerAgent agent = PlannerAgent.builder() + .name("pipeline") + .subAgents(extractAgent, transformAgent, loadAgent) + .planner(new SequentialPlanner()) + .build(); +``` + +``` +Execution: extractAgent ──> transformAgent ──> loadAgent ──> Done +``` + +Internally uses a cursor that increments after each agent. Returns `Done` when the cursor exceeds the agent count. + +### ParallelPlanner + +Runs all sub-agents in parallel on the first action, then completes immediately. + +```java +PlannerAgent agent = PlannerAgent.builder() + .name("fanout") + .subAgents(searchWeb, searchDocs, searchCode) + .planner(new ParallelPlanner()) + .build(); +``` + +``` +Execution: [searchWeb, searchDocs, searchCode] ──> Done + (all in parallel) +``` + +The simplest planner — stateless, no configuration. `firstAction` returns `RunAgents(allAgents)`, `nextAction` always returns `Done`. + +### LoopPlanner + +Cycles through sub-agents repeatedly, stopping when the cycle count is reached or an escalate event is detected. + +```java +PlannerAgent agent = PlannerAgent.builder() + .name("reviewer") + .subAgents(draftAgent, reviewAgent) + .planner(new LoopPlanner(3)) // max 3 cycles + .build(); +``` + +``` +Execution: draftAgent ──> reviewAgent ──> draftAgent ──> reviewAgent ──> ... ──> Done + ├─── cycle 1 ───┤ ├─── cycle 2 ───┤ +``` + +**Termination conditions:** +1. `cycleCount >= maxCycles` — hard limit on cycles +2. Escalate event — if the last event has `event.actions().escalate() == true`, the loop stops + +### SupervisorPlanner + +Uses an LLM to dynamically decide which agent(s) to run next. The LLM receives a prompt with available agents, current state, recent events, and its own decision history. + +```java +PlannerAgent agent = PlannerAgent.builder() + .name("supervisor") + .subAgents(researchAgent, analyzeAgent, writeAgent) + .planner(new SupervisorPlanner(llm, "You coordinate a research team.", 20)) + .build(); +``` + +**Constructor variants:** +- `SupervisorPlanner(BaseLlm llm)` — minimal; no system instruction, default maxEvents=20 +- `SupervisorPlanner(BaseLlm llm, String systemInstruction)` — custom system prompt +- `SupervisorPlanner(BaseLlm llm, String systemInstruction, int maxEvents)` — full control + +**Prompt structure** (built automatically): +1. Available agents with descriptions +2. Current state keys +3. Recent events (sliding window of `maxEvents`, default 20) +4. Decision history (all prior decisions in order) +5. Original user request (if available) + +**LLM response parsing:** +- `"DONE"` → `PlannerAction.Done` +- `"DONE: "` → `PlannerAction.DoneWithResult(summary)` +- `"agentName"` → `PlannerAction.RunAgents(agent)` +- `"agent1,agent2"` → `PlannerAction.RunAgents(agent1, agent2)` (parallel) +- Unknown agent name → falls back to `Done` (with warning log) +- LLM call failure → falls back to `Done` (via `onErrorReturn`) + +### Simple Planners Comparison + +| | SequentialPlanner | ParallelPlanner | LoopPlanner | SupervisorPlanner | +|-|:-:|:-:|:-:|:-:| +| **Internal State** | cursor | none | cursor + cycleCount | decisionHistory | +| **Configuration** | none | none | maxCycles | llm, systemInstruction, maxEvents | +| **Deterministic** | Yes | Yes | Yes* | No | +| **LLM Required** | No | No | No | Yes | +| **Termination** | All agents run | After first action | maxCycles or escalate | LLM says DONE | + +*\* LoopPlanner is deterministic in ordering but the escalation check depends on agent behavior.* + +--- + +## 4. Goal-Oriented Action Planning (GOAP) + +The GOAP subsystem (`com.google.adk.planner.goap`) resolves agent execution order by analyzing input/output dependencies between agents. Given a target goal, it computes which agents need to run and in what order, grouping independent agents for parallel execution. + +This approach is inspired by Goal-Oriented Action Planning from game AI, adapted for agent orchestration: instead of game-world states and character actions, the "world state" is session state keys and "actions" are sub-agents with declared I/O contracts. + +### AgentMetadata + +Each agent declares what state keys it reads (inputs) and writes (output): + +```java +public record AgentMetadata( + String agentName, // must match BaseAgent.name() + ImmutableList inputKeys, // state keys the agent reads + String outputKey // state key the agent produces +) {} +``` + +Example — a horoscope pipeline: + +```java +List metadata = List.of( + new AgentMetadata("personExtractor", ImmutableList.of("prompt"), "person"), + new AgentMetadata("signExtractor", ImmutableList.of("prompt"), "sign"), + new AgentMetadata("horoscopeGen", ImmutableList.of("person", "sign"), "horoscope"), + new AgentMetadata("writer", ImmutableList.of("person", "horoscope"), "writeup") +); +``` + +### Dependency Graph + +`GoalOrientedSearchGraph` builds an immutable dependency graph from the metadata: + +``` + prompt (precondition) + / \ + personExtractor signExtractor + | "person" | "sign" + | | + └───────┬────────────────┘ + v + horoscopeGen + | "horoscope" + | + v + writer ──> "writeup" (goal) +``` + +The graph maintains two mappings: +- `outputKey → agentName` — which agent produces each output +- `outputKey → inputKeys` — what dependencies each output requires + +Duplicate output keys across agents cause an `IllegalArgumentException`. + +### Search Strategies + +The `SearchStrategy` interface defines how the dependency graph is traversed to produce execution groups: + +```java +public interface SearchStrategy { + ImmutableList> searchGrouped( + GoalOrientedSearchGraph graph, + List metadata, + Collection preconditions, + String goal); +} +``` + +Two implementations are provided: + +#### DfsSearchStrategy (default) + +**Backward-chaining depth-first search.** Starts at the goal and recursively resolves dependencies. Uses a `visiting` set for cycle detection and a `satisfied` set for precondition skipping. + +``` +Goal: "writeup" + └─ needs "person", "horoscope" + └─ "horoscope" needs "person", "sign" + └─ "person" needs "prompt" (precondition ✓) + └─ "sign" needs "prompt" (precondition ✓) + └─ "person" (already resolved) + +Flat order: personExtractor, signExtractor, horoscopeGen, writer +``` + +#### AStarSearchStrategy + +**Forward A\* search** from preconditions toward the goal. Uses a priority queue ordered by f-score: +- **g** = number of agents activated so far (uniform cost) +- **h** = admissible heuristic counting unsatisfied dependencies reachable backward from the goal + +The heuristic performs a breadth-first backward traversal from the goal, counting keys not yet in the activated set. Since each unsatisfied key requires at least one agent, this never overestimates. + +#### Parallel Level Assignment + +Both strategies ultimately use `DependencyGraphSearch.assignParallelLevels()` to group agents for parallel execution. Each agent's level is computed as: + +``` +level(agent) = 1 + max(level(dependency_agents)) +``` + +Agents at the same level have no mutual dependencies and run in parallel: + +``` +Level 0: [personExtractor, signExtractor] ← independent, run in parallel +Level 1: [horoscopeGen] ← waits for level 0 +Level 2: [writer] ← waits for level 1 +``` + +Both DFS and A\* produce identical groupings for any valid DAG — this is verified by cross-strategy equivalence tests. + +### GoalOrientedPlanner + +The planner that ties it all together: + +```java +// Default: DFS search + Ignore policy +GoalOrientedPlanner planner = new GoalOrientedPlanner("writeup", metadata); + +// With A* search and Replan policy +GoalOrientedPlanner planner = new GoalOrientedPlanner( + "writeup", metadata, new AStarSearchStrategy(), new ReplanPolicy.Replan(3)); + +PlannerAgent agent = PlannerAgent.builder() + .name("horoscope") + .subAgents(personExtractor, signExtractor, horoscopeGen, writer) + .planner(planner) + .build(); +``` + +**Lifecycle:** +1. `init()` — builds the dependency graph and computes execution groups via the search strategy. Keys already present in session state are treated as satisfied preconditions (skipping agents that produce them). +2. `firstAction()` — returns the first group of agents to run +3. `nextAction()` — checks for missing outputs from the previous group, applies the replan policy if needed, then returns the next group + +**Constructor variants:** +- `GoalOrientedPlanner(goal, metadata)` — DFS + Ignore (default) +- `GoalOrientedPlanner(goal, metadata, validateOutputs)` — DFS + FailStop (true) or Ignore (false) +- `GoalOrientedPlanner(goal, metadata, searchStrategy, replanPolicy)` — full control + +### ReplanPolicy + +A sealed interface governing how the planner reacts when agents don't produce their expected outputs: + +```java +public sealed interface ReplanPolicy permits FailStop, Replan, Ignore { + record FailStop() implements ReplanPolicy {} + record Replan(int maxAttempts) implements ReplanPolicy {} // maxAttempts >= 1 + record Ignore() implements ReplanPolicy {} +} +``` + +| Policy | On Missing Output | Attempt Tracking | Termination | +|--------|-------------------|:---:|-------------| +| `Ignore` (default) | Proceeds with remaining plan | No | Normal completion | +| `FailStop` | Halts immediately | No | `DoneWithResult` with error listing missing outputs | +| `Replan(n)` | Rebuilds plan from current state | Yes, resets on success | `DoneWithResult` after n consecutive failures | + +**Replanning flow:** + +``` + nextAction() called + │ + ┌──────┴──────┐ + │ outputs │ + │ missing? │ + └──────┬──────┘ + No │ Yes + │ │ │ + reset │ │ ├── Ignore ──> proceed with current plan + replan │ │ ├── FailStop ─> DoneWithResult(error) + count │ │ └── Replan ──> replanCount < max? + │ │ │ │ + v │ Yes No + select │ │ │ + next │ rebuild plan DoneWithResult + group │ from current (exhausted) + │ state + v +``` + +The replan counter tracks **consecutive** failures. It resets to zero whenever a group completes successfully. + +### Council Topology Example + +A realistic 9-agent pipeline tested extensively in the codebase: + +``` + initial_response + / | \ + peer_ranking agreement disagreement + | _analysis _analysis + | | | + aggregate_rankings aggregate aggregate + | _agreements _disagreements + | | | + └──── final_synthesis ─────┘ + | + council_summary +``` + +This produces 4 execution groups: +1. `[initial_response]` +2. `[peer_ranking, agreement_analysis, disagreement_analysis]` +3. `[final_synthesis, aggregate_rankings, aggregate_agreements, aggregate_disagreements]` +4. `[council_summary]` + +--- + +## 5. Peer-to-Peer (P2P) Planner + +The P2P planner (`com.google.adk.planner.p2p`) takes a fundamentally different approach from GOAP: instead of computing an execution plan upfront, agents activate dynamically as their input dependencies become available in session state. + +### Concepts + +- **No upfront plan** — agents are not pre-ordered; they activate when their inputs appear +- **Parallel activation** — multiple agents can activate simultaneously when their inputs are satisfied +- **Iterative refinement** — when an agent produces a new or changed output, downstream agents re-execute +- **Value-change detection** — `Objects.equals()` comparison prevents spurious re-activation when an output is written but unchanged + +### AgentActivator + +Each agent is wrapped in an `AgentActivator` that tracks its activation state: + +``` + ┌───────────────┐ + │ AgentActivator │ + ├───────────────┤ + init ──────> │ shouldExecute │ = true + │ executing │ = false + └───────┬───────┘ + │ + canActivate(state)? + = !executing && shouldExecute + && all inputKeys present in state + │ + ┌────┴────┐ + │ Yes │ + └────┬────┘ + │ + startExecution() + executing=true, shouldExecute=false + │ + (agent runs) + │ + finishExecution() + executing=false + │ + onStateChanged(key)? + if key in inputKeys: shouldExecute=true + │ + (may re-activate) +``` + +### P2PPlanner Usage + +```java +List metadata = List.of( + new AgentMetadata("literature", ImmutableList.of("topic"), "researchFindings"), + new AgentMetadata("hypothesis", ImmutableList.of("topic", "researchFindings"), "hypothesis"), + new AgentMetadata("critic", ImmutableList.of("topic", "hypothesis"), "critique"), + new AgentMetadata("scorer", ImmutableList.of("topic", "hypothesis", "critique"), "score") +); + +// Exit when score is high enough +P2PPlanner planner = new P2PPlanner(metadata, 20, + (state, count) -> { + Object score = state.get("score"); + return score instanceof Number && ((Number) score).doubleValue() >= 0.85; + }); + +PlannerAgent agent = PlannerAgent.builder() + .name("research") + .subAgents(literatureAgent, hypothesisAgent, criticAgent, scorerAgent) + .planner(planner) + .build(); +``` + +**Constructor variants:** +- `P2PPlanner(metadata, maxInvocations)` — exits only on max invocations +- `P2PPlanner(metadata, maxInvocations, exitCondition)` — custom `BiPredicate, Integer>` + +### Termination + +Three conditions, checked in this order: +1. **Exit condition** — `exitCondition.test(state, invocationCount)` returns true +2. **Max invocations** — `invocationCount >= maxInvocations` +3. **No activatable agents** — no agent can activate (all are waiting for inputs or already ran without new inputs) + +### Iterative Refinement + +When an agent produces a changed output value, all agents that have that key in their `inputKeys` get marked for re-execution: + +``` +Wave 1: literature (topic present) → produces researchFindings +Wave 2: hypothesis (topic + researchFindings) → produces hypothesis +Wave 3: critic (topic + hypothesis) → produces critique +Wave 4: scorer (topic + hypothesis + critique) → produces score (0.6) + + ← score too low, critic's output changes next round → + +Wave 5: hypothesis re-activates (critique changed) → updated hypothesis +Wave 6: critic re-activates (hypothesis changed) → updated critique +Wave 7: scorer re-activates → produces score (0.87) → exit condition met +``` + +Only **actual value changes** trigger re-activation. If an agent produces the same value (checked via `Objects.equals()`), downstream agents are not notified. + +### GOAP vs P2P + +| Dimension | GOAP | P2P | +|-----------|------|-----| +| **Plan computation** | Upfront (at init) | None; reactive | +| **Execution order** | Pre-determined groups | Dynamic waves | +| **Parallelism** | Agents grouped by dependency level | Agents activate when inputs ready | +| **Failure handling** | ReplanPolicy (Ignore/FailStop/Replan) | N/A (agents simply don't activate) | +| **Re-execution** | No (each agent runs once) | Yes (on input value change) | +| **State-change sensitivity** | Checks presence of output keys | Checks both presence and value equality | +| **Best for** | Known dependency DAGs, one-shot workflows | Iterative refinement, collaborative loops | + +--- + +## 6. Choosing a Planner + +### Decision Flowchart + +``` + ┌─────────────────────┐ + │ Are agents fully │ + │ independent? │ + └──────────┬──────────┘ + Yes │ No + │ │ │ + v │ v + ParallelPlanner ┌─────────────────────┐ + │ Is the execution │ + │ order fixed? │ + └──────────┬──────────┘ + Yes │ No + │ │ │ + v │ v + SequentialPlanner ┌─────────────────────┐ + │ Need iterative │ + │ cycles? │ + └──────────┬──────────┘ + Yes │ No + │ │ │ + v │ v + LoopPlanner ┌─────────────────────┐ + │ Should an LLM │ + │ decide dynamically? │ + └──────────┬──────────┘ + Yes │ No + │ │ │ + v │ v + SupervisorPlanner│ ┌─────────────────────┐ + │ │ Do agents have │ + │ │ I/O dependencies? │ + │ └──────────┬──────────┘ + │ Yes │ + │ │ │ + │ v │ + │ ┌────────┴────────┐ + │ │ Need iterative │ + │ │ refinement? │ + │ └────────┬────────┘ + │ No │ Yes + │ │ │ │ + │ v │ v + │ GoalOrientedPlanner + │ │ P2PPlanner + │ │ + └────────────┘ +``` + +### Use Case Catalog + +| Scenario | Recommended Planner | Why | +|----------|-------------------|-----| +| ETL pipeline (extract → transform → load) | `SequentialPlanner` | Fixed order, each step depends on the previous | +| Fan-out aggregation (search multiple sources) | `ParallelPlanner` | Independent tasks, no ordering needed | +| Draft-review cycles | `LoopPlanner` | Iterative passes with escalation-based exit | +| Open-ended task delegation | `SupervisorPlanner` | LLM decides what to do based on context | +| Multi-step workflow with dependencies | `GoalOrientedPlanner` | Agents declare I/O; planner resolves order automatically | +| Research collaboration with critic feedback | `P2PPlanner` | Agents re-execute as inputs refine | + +### Composability + +`PlannerAgent` is itself a `BaseAgent`, so planners can be nested. A GOAP planner can orchestrate sub-agents where one of those sub-agents is itself a `PlannerAgent` with a `LoopPlanner` inside: + +```java +// Inner: draft-review loop +PlannerAgent reviewLoop = PlannerAgent.builder() + .name("reviewLoop") + .subAgents(draftAgent, reviewAgent) + .planner(new LoopPlanner(3)) + .build(); + +// Outer: GOAP pipeline that includes the review loop +List metadata = List.of( + new AgentMetadata("research", ImmutableList.of("topic"), "findings"), + new AgentMetadata("reviewLoop", ImmutableList.of("findings"), "reviewed"), + new AgentMetadata("publish", ImmutableList.of("reviewed"), "published") +); + +PlannerAgent pipeline = PlannerAgent.builder() + .name("pipeline") + .subAgents(researchAgent, reviewLoop, publishAgent) + .planner(new GoalOrientedPlanner("published", metadata)) + .build(); +``` + +--- + +## 7. Advanced Topics + +### Implementing a Custom Planner + +Implement the `Planner` interface: + +```java +public class PriorityPlanner implements Planner { + + @Override + public void init(PlanningContext context) { + // Optional: build data structures, analyze agents + } + + @Override + public Single firstAction(PlanningContext context) { + // Return the first action + BaseAgent highest = selectHighestPriority(context); + return Single.just(new PlannerAction.RunAgents(highest)); + } + + @Override + public Single nextAction(PlanningContext context) { + // Inspect updated state and decide + if (isGoalMet(context.state())) { + return Single.just(new PlannerAction.Done()); + } + return Single.just(new PlannerAction.RunAgents(selectHighestPriority(context))); + } +} +``` + +For async planners (e.g., calling an LLM), return the `Single` from the async call: + +```java +@Override +public Single nextAction(PlanningContext context) { + return llm.generateContent(request, false) + .lastOrError() + .map(response -> parseActionFromResponse(response)); +} +``` + +### Session State as World State + +Session state (`PlanningContext.state()`) is the shared "world state" that connects agents and planners: + +- **Agents write** to state via event `stateDelta` — when an agent emits an event, the state delta is applied to the session +- **Planners read** state to make decisions — `context.state()` reflects the current state after all prior agents have run +- **Keys are the contract** — `AgentMetadata` declares which state keys an agent reads and writes; both GOAP and P2P planners use these declarations for dependency resolution + +### Error Handling and Resilience + +**SupervisorPlanner:** +- LLM call failures are caught via `onErrorReturn` and fall back to `Done` (with a warning log) +- Unknown agent names in LLM responses fall back to `Done` (with a warning log) + +**GoalOrientedPlanner:** +- Unresolvable dependencies throw `IllegalStateException` at `init` time +- Circular dependencies are detected and throw `IllegalStateException` +- Missing outputs after agent execution are handled by `ReplanPolicy` + +**PlannerAgent:** +- `maxIterations` (default 100) prevents infinite planning loops + +### maxIterations vs maxInvocations vs maxCycles + +Three different bounds apply at different levels: + +| Bound | Where | Default | What It Limits | +|-------|-------|:---:|----------------| +| `maxIterations` | `PlannerAgent.builder()` | 100 | Total planning loop iterations (across all planners) | +| `maxInvocations` | `P2PPlanner` constructor | (required) | Total agent invocations in P2P planning | +| `maxCycles` | `LoopPlanner` constructor | (required) | Complete cycles through the agent list | + +### Callbacks + +`PlannerAgent.Builder` inherits `beforeAgentCallback` and `afterAgentCallback` from `BaseAgent.Builder`: + +```java +PlannerAgent agent = PlannerAgent.builder() + .name("pipeline") + .subAgents(agentA, agentB) + .planner(new SequentialPlanner()) + .beforeAgentCallback(List.of((ctx, agentName) -> { + // Called before each sub-agent runs + return Single.just(true); // return false to skip the agent + })) + .build(); +``` + +--- + +## 8. Testing + +### Test Stack + +| Component | Library | +|-----------|---------| +| Test framework | JUnit 5 (`@Test`, `@Nested`) | +| Assertions | Google Truth (`assertThat(...).containsExactly(...)`) | +| Mocking | Mockito (used for `BaseLlm` in SupervisorPlanner tests) | +| Reactive testing | RxJava 3 `.blockingGet()` for synchronous test execution | + +### Test Organization + +Tests mirror the source package structure: + +``` +src/test/java/com/google/adk/ +├── agents/ +│ └── PlannerAgentTest.java # PlannerAgent integration +└── planner/ + ├── SequentialPlannerTest.java # Sequential execution + ├── ParallelPlannerTest.java # Parallel execution + ├── LoopPlannerTest.java # Cyclic execution + escalation + ├── SupervisorPlannerTest.java # LLM-driven selection + prompt building + ├── goap/ + │ ├── AStarSearchStrategyTest.java # A* graph traversal + │ ├── GoalOrientedPlannerTest.java # GOAP planning + dependency resolution + │ ├── ReplanningTest.java # Replan policy behavior + │ └── CouncilTopologyTest.java # 9-agent DAG (GOAP behavior) + └── p2p/ + ├── P2PPlannerTest.java # Reactive activation + refinement + └── P2PCouncilTopologyTest.java # 9-agent DAG (P2P behavior) +``` + +Larger test suites use `@Nested` classes to group related scenarios. For example, `CouncilTopologyTest` organizes into: +- `GoapPlanningBehavior` — group structure and precondition skipping +- `AdaptiveGoapReplanning` — replan policy scenarios +- `EdgeCases` — partial failures, policy comparisons + +### Test Patterns + +**Minimal test agent** — all tests use a `SimpleTestAgent` that extends `BaseAgent` and returns `Flowable.empty()`: + +```java +class SimpleTestAgent extends BaseAgent { + SimpleTestAgent(String name) { super(name, name + " description", ImmutableList.of()); } + @Override protected Flowable runAsyncImpl(InvocationContext ctx) { + return Flowable.empty(); + } +} +``` + +**Context creation** — tests create a `PlanningContext` with `InMemorySessionService` and a `ConcurrentHashMap` for state: + +```java +PlanningContext context = createPlanningContext(agents, new ConcurrentHashMap<>()); +``` + +**Plan walking** — tests walk the planning loop by calling `firstAction`/`nextAction` until `Done`: + +```java +PlannerAction action = planner.firstAction(context).blockingGet(); +while (action instanceof PlannerAction.RunAgents runAgents) { + simulateSuccess(context, agentNames(runAgents)); + action = planner.nextAction(context).blockingGet(); +} +``` + +**State injection** — tests simulate agent output by directly updating `context.state()`: + +```java +context.state().put("person", "Alice"); +context.state().put("sign", "Aries"); +``` + +**Strategy equivalence** — A\* vs DFS equivalence is verified on multiple topologies: + +```java +assertThat(astarGroups).isEqualTo(dfsGroups); +``` + +### Test Coverage + +| Test Class | Focus | Notable Scenarios | +|-----------|-------|-------------------| +| `PlannerAgentTest` | Integration loop | State sharing, maxIterations, NoOp handling | +| `SequentialPlannerTest` | Ordering | Cursor reset, empty agents | +| `ParallelPlannerTest` | Fan-out | Single agent, empty agents | +| `LoopPlannerTest` | Cycling | maxCycles, escalate event detection | +| `SupervisorPlannerTest` | LLM interaction | Prompt construction, decision history, error fallback | +| `AStarSearchStrategyTest` | Graph search | Linear, diamond, deep chains, cycle detection, DFS equivalence | +| `GoalOrientedPlannerTest` | GOAP planning | Dependency resolution, parallel grouping, output validation | +| `ReplanningTest` | Failure handling | Counter reset, max attempts, policy comparison | +| `CouncilTopologyTest` | Complex GOAP | 9-agent DAG, partial failure, cross-strategy equivalence | +| `P2PPlannerTest` | Reactive activation | Value-change detection, exit conditions, maxInvocations | +| `P2PCouncilTopologyTest` | Complex P2P | Wave activation, iterative refinement, termination | + +### Running Tests + +```bash +mvn test -pl contrib/planners +``` + +--- + +## 9. Package Reference + +### Source Layout + +``` +contrib/planners/src/main/java/com/google/adk/ +├── agents/ +│ ├── Planner.java +│ ├── PlannerAction.java +│ ├── PlannerAgent.java +│ └── PlanningContext.java +└── planner/ + ├── SequentialPlanner.java + ├── ParallelPlanner.java + ├── LoopPlanner.java + ├── SupervisorPlanner.java + ├── goap/ + │ ├── GoalOrientedPlanner.java + │ ├── AgentMetadata.java + │ ├── SearchStrategy.java + │ ├── DfsSearchStrategy.java + │ ├── AStarSearchStrategy.java + │ ├── DependencyGraphSearch.java + │ ├── GoalOrientedSearchGraph.java + │ └── ReplanPolicy.java + └── p2p/ + ├── P2PPlanner.java + └── AgentActivator.java +``` + +### Class Index + +| Package | Class | Type | Purpose | +|---------|-------|------|---------| +| `agents` | `Planner` | interface | Strategy for selecting next agent(s) | +| `agents` | `PlannerAction` | sealed interface | Four-variant action result type | +| `agents` | `PlannerAgent` | class | Orchestrating agent that runs the planning loop | +| `agents` | `PlanningContext` | class | State, events, and agents available to planners | +| `planner` | `SequentialPlanner` | final class | One-at-a-time sequential execution | +| `planner` | `ParallelPlanner` | final class | All-at-once parallel execution | +| `planner` | `LoopPlanner` | final class | Cyclic execution with escalation detection | +| `planner` | `SupervisorPlanner` | final class | LLM-driven dynamic agent selection | +| `planner.goap` | `GoalOrientedPlanner` | final class | Dependency-resolved planning with replanning | +| `planner.goap` | `AgentMetadata` | record | Agent input/output key declarations | +| `planner.goap` | `SearchStrategy` | interface | Strategy for dependency graph search | +| `planner.goap` | `DfsSearchStrategy` | final class | Backward-chaining DFS search | +| `planner.goap` | `AStarSearchStrategy` | final class | Forward A* search with admissible heuristic | +| `planner.goap` | `DependencyGraphSearch` | final class | Topological search and parallel level assignment | +| `planner.goap` | `GoalOrientedSearchGraph` | final class | Immutable dependency graph data structure | +| `planner.goap` | `ReplanPolicy` | sealed interface | Failure handling policy (Ignore/FailStop/Replan) | +| `planner.p2p` | `P2PPlanner` | final class | Reactive dynamic activation with refinement | +| `planner.p2p` | `AgentActivator` | final class (pkg) | Per-agent activation state tracking | + +--- + +## 10. License + +``` +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. +``` diff --git a/contrib/planners/pom.xml b/contrib/planners/pom.xml new file mode 100644 index 000000000..96f6bba8d --- /dev/null +++ b/contrib/planners/pom.xml @@ -0,0 +1,72 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.0.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 + + + + + com.google.adk + google-adk + ${project.version} + test-jar + test + + + 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/agents/Planner.java b/contrib/planners/src/main/java/com/google/adk/agents/Planner.java new file mode 100644 index 000000000..cc6e741a2 --- /dev/null +++ b/contrib/planners/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/contrib/planners/src/main/java/com/google/adk/agents/PlannerAction.java b/contrib/planners/src/main/java/com/google/adk/agents/PlannerAction.java new file mode 100644 index 000000000..f05dfaf1e --- /dev/null +++ b/contrib/planners/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/contrib/planners/src/main/java/com/google/adk/agents/PlannerAgent.java b/contrib/planners/src/main/java/com/google/adk/agents/PlannerAgent.java new file mode 100644 index 000000000..909845b16 --- /dev/null +++ b/contrib/planners/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().orElse(null)) + .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/contrib/planners/src/main/java/com/google/adk/agents/PlanningContext.java b/contrib/planners/src/main/java/com/google/adk/agents/PlanningContext.java new file mode 100644 index 000000000..be8c81d99 --- /dev/null +++ b/contrib/planners/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.Map; +import java.util.Optional; + +/** + * 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 Map 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/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..445e9679a --- /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; + +/** + * 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; + // Mutable state — planners are used within a single reactive pipeline and are not thread-safe. + private int cursor; + private int cycleCount; + private ImmutableList agents; + + public LoopPlanner(int maxCycles) { + this.maxCycles = maxCycles; + } + + @Override + public void init(PlanningContext context) { + agents = context.availableAgents(); + cursor = 0; + cycleCount = 0; + } + + @Override + public Single firstAction(PlanningContext context) { + cursor = 0; + cycleCount = 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++; + if (idx >= agents.size()) { + int cycle = ++cycleCount; + if (cycle >= maxCycles) { + return Single.just(new PlannerAction.Done()); + } + cursor = 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..1ace681ad --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/SequentialPlanner.java @@ -0,0 +1,56 @@ +/* + * 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; + +/** A planner that runs sub-agents one at a time in order. */ +public final class SequentialPlanner implements Planner { + + // Mutable state — planners are used within a single reactive pipeline and are not thread-safe. + private int cursor; + private ImmutableList agents; + + @Override + public void init(PlanningContext context) { + agents = context.availableAgents(); + cursor = 0; + } + + @Override + public Single firstAction(PlanningContext context) { + cursor = 0; + return selectNext(); + } + + @Override + public Single nextAction(PlanningContext context) { + return selectNext(); + } + + private Single selectNext() { + if (agents == null || cursor >= agents.size()) { + return Single.just(new PlannerAction.Done()); + } + return Single.just(new PlannerAction.RunAgents(agents.get(cursor++))); + } +} 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..9f40b3514 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/SupervisorPlanner.java @@ -0,0 +1,208 @@ +/* + * 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.ArrayList; +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 static final int DEFAULT_MAX_EVENTS = 20; + + private final BaseLlm llm; + private final Optional systemInstruction; + private final int maxEvents; + private final List decisionHistory = new ArrayList<>(); + + public SupervisorPlanner(BaseLlm llm, String systemInstruction, int maxEvents) { + this.llm = llm; + this.systemInstruction = Optional.ofNullable(systemInstruction); + this.maxEvents = maxEvents; + } + + public SupervisorPlanner(BaseLlm llm, String systemInstruction) { + this(llm, systemInstruction, DEFAULT_MAX_EVENTS); + } + + public SupervisorPlanner(BaseLlm llm) { + this(llm, null, DEFAULT_MAX_EVENTS); + } + + @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); + PlannerAction action = parseResponse(text, context); + recordDecision(action); + return action; + }) + .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() - maxEvents); + for (int i = start; i < events.size(); i++) { + Event event = events.get(i); + sb.append("- ") + .append(event.author()) + .append(": ") + .append(event.stringifyContent()) + .append("\n"); + } + } + + if (!decisionHistory.isEmpty()) { + sb.append("\nPrevious decisions (in order):\n"); + for (int i = 0; i < decisionHistory.size(); i++) { + sb.append(i + 1).append(". ").append(decisionHistory.get(i)).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); + } + + private void recordDecision(PlannerAction action) { + if (action instanceof PlannerAction.RunAgents run) { + decisionHistory.add( + "Run: " + run.agents().stream().map(BaseAgent::name).collect(Collectors.joining(", "))); + } else if (action instanceof PlannerAction.DoneWithResult done) { + decisionHistory.add("Done: " + done.result()); + } else if (action instanceof PlannerAction.Done) { + decisionHistory.add("Done"); + } + } +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/goap/AStarSearchStrategy.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/AStarSearchStrategy.java new file mode 100644 index 000000000..3c28722f2 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/AStarSearchStrategy.java @@ -0,0 +1,168 @@ +/* + * 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.ImmutableSet; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.Set; + +/** + * A* forward search strategy that explores from preconditions toward the goal, activating agents + * whose inputs are all satisfied. + * + *

Uses a priority queue ordered by f-score (g + h) where: + * + *

    + *
  • g = number of agents activated so far (uniform cost) + *
  • h = admissible heuristic counting unsatisfied dependencies reachable backward from goal + *
+ * + *

After finding the goal, reconstructs the agent path and delegates to {@link + * DependencyGraphSearch#assignParallelLevels} for parallel grouping. + */ +public final class AStarSearchStrategy implements SearchStrategy { + + /** Immutable search state: the set of output keys that have been "activated" (produced). */ + private record SearchState(ImmutableSet activatedKeys) {} + + /** Priority queue entry tracking cost, heuristic, and parent chain for path reconstruction. */ + private record StateScore( + SearchState state, double gScore, double fScore, String lastActivatedAgent, StateScore parent) + implements Comparable { + + @Override + public int compareTo(StateScore other) { + return Double.compare(this.fScore, other.fScore); + } + } + + @Override + public ImmutableList> searchGrouped( + GoalOrientedSearchGraph graph, + List metadata, + Collection preconditions, + String goal) { + + ImmutableSet initialActivated = ImmutableSet.copyOf(preconditions); + + // Goal already satisfied + if (initialActivated.contains(goal)) { + return ImmutableList.of(); + } + + PriorityQueue openSet = new PriorityQueue<>(); + Set> visited = new HashSet<>(); + + SearchState startState = new SearchState(initialActivated); + double h0 = heuristic(graph, startState, goal); + openSet.add(new StateScore(startState, 0.0, h0, null, null)); + + while (!openSet.isEmpty()) { + StateScore current = openSet.poll(); + + if (current.state.activatedKeys.contains(goal)) { + ImmutableList agentPath = reconstructPath(current); + return DependencyGraphSearch.assignParallelLevels( + agentPath, metadata, preconditions, graph); + } + + if (!visited.add(current.state.activatedKeys)) { + continue; + } + + // Find activatable agents: those whose ALL inputKeys are in activatedKeys + for (AgentMetadata agent : metadata) { + if (current.state.activatedKeys.contains(agent.outputKey())) { + continue; // already activated + } + if (!current.state.activatedKeys.containsAll(agent.inputKeys())) { + continue; // not all inputs satisfied + } + + ImmutableSet newActivated = + ImmutableSet.builder() + .addAll(current.state.activatedKeys) + .add(agent.outputKey()) + .build(); + + if (visited.contains(newActivated)) { + continue; + } + + SearchState newState = new SearchState(newActivated); + double newG = current.gScore + 1.0; + double newH = heuristic(graph, newState, goal); + double newF = newG + newH; + + openSet.add(new StateScore(newState, newG, newF, agent.agentName(), current)); + } + } + + throw new IllegalStateException( + "Cannot reach goal '" + + goal + + "': no sequence of agents can produce it from the given preconditions."); + } + + /** + * Admissible heuristic: counts unsatisfied output keys reachable backward from the goal. + * + *

Each unsatisfied key requires at least one agent to produce it, so this never overestimates. + */ + private static double heuristic(GoalOrientedSearchGraph graph, SearchState state, String goal) { + Queue queue = new ArrayDeque<>(); + Set seen = new HashSet<>(); + int unsatisfied = 0; + + queue.add(goal); + while (!queue.isEmpty()) { + String key = queue.poll(); + if (!seen.add(key)) { + continue; + } + if (!state.activatedKeys.contains(key)) { + unsatisfied++; + if (graph.contains(key)) { + for (String dep : graph.getDependencies(key)) { + queue.add(dep); + } + } + } + } + return unsatisfied; + } + + /** Reconstructs the ordered agent path by following the parent chain. */ + private static ImmutableList reconstructPath(StateScore goalState) { + List path = new ArrayList<>(); + StateScore current = goalState; + while (current != null && current.lastActivatedAgent != null) { + path.add(current.lastActivatedAgent); + current = current.parent; + } + Collections.reverse(path); + return ImmutableList.copyOf(path); + } +} 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..0a730f413 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/DependencyGraphSearch.java @@ -0,0 +1,190 @@ +/* + * 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.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +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); + } + + /** + * Groups agents into parallelizable execution levels. + * + *

Each group contains agents whose dependencies are all satisfied by agents in earlier groups + * or by initial preconditions. Agents within the same group are independent and can run in + * parallel. + * + * @param graph the dependency graph + * @param metadata agent metadata used to compute dependency levels + * @param preconditions state keys already available + * @param goal the target output key + * @return ordered list of agent groups; agents within each group can run in parallel + * @throws IllegalStateException if a dependency cannot be resolved or a cycle is detected + */ + public static ImmutableList> searchGrouped( + GoalOrientedSearchGraph graph, + List metadata, + Collection preconditions, + String goal) { + + ImmutableList flatOrder = search(graph, preconditions, goal); + return assignParallelLevels(flatOrder, metadata, preconditions, graph); + } + + /** + * Assigns agents from a flat execution order into parallelizable groups based on dependency + * depth. + * + *

Each agent's level is {@code 1 + max(level of its dependency agents)}. Agents at the same + * level have no mutual dependencies and can run in parallel. + * + * @param flatOrder ordered list of agent names (topological order) + * @param metadata agent metadata for dependency lookup + * @param preconditions state keys already available + * @param graph the dependency graph + * @return ordered list of agent groups for parallel execution + */ + static ImmutableList> assignParallelLevels( + ImmutableList flatOrder, + List metadata, + Collection preconditions, + GoalOrientedSearchGraph graph) { + + if (flatOrder.isEmpty()) { + return ImmutableList.of(); + } + + Map agentToMeta = new HashMap<>(); + for (AgentMetadata m : metadata) { + agentToMeta.put(m.agentName(), m); + } + + // Assign execution levels: level = 1 + max(level of dependency agents). + // Agents at the same level have no mutual dependencies and can run in parallel. + Set preconSet = new HashSet<>(preconditions); + Map agentLevel = new LinkedHashMap<>(); + + for (String agentName : flatOrder) { + AgentMetadata meta = agentToMeta.get(agentName); + int maxDepLevel = -1; + + for (String inputKey : meta.inputKeys()) { + if (preconSet.contains(inputKey)) { + continue; + } + String producerAgent = graph.getProducerAgent(inputKey); + if (producerAgent != null && agentLevel.containsKey(producerAgent)) { + maxDepLevel = Math.max(maxDepLevel, agentLevel.get(producerAgent)); + } + } + + agentLevel.put(agentName, maxDepLevel + 1); + } + + int maxLevel = agentLevel.values().stream().mapToInt(Integer::intValue).max().orElse(0); + ImmutableList.Builder> groups = ImmutableList.builder(); + for (int level = 0; level <= maxLevel; level++) { + final int l = level; + ImmutableList group = + flatOrder.stream() + .filter(name -> agentLevel.get(name) == l) + .collect(ImmutableList.toImmutableList()); + if (!group.isEmpty()) { + groups.add(group); + } + } + + return groups.build(); + } + + 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/DfsSearchStrategy.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/DfsSearchStrategy.java new file mode 100644 index 000000000..964e7911b --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/DfsSearchStrategy.java @@ -0,0 +1,38 @@ +/* + * 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.List; + +/** + * Backward-chaining DFS search strategy with parallel grouping. + * + *

Delegates to {@link DependencyGraphSearch} for the actual algorithm. + */ +public final class DfsSearchStrategy implements SearchStrategy { + + @Override + public ImmutableList> searchGrouped( + GoalOrientedSearchGraph graph, + List metadata, + Collection preconditions, + String goal) { + return DependencyGraphSearch.searchGrouped(graph, metadata, preconditions, goal); + } +} 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..b340b8129 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/GoalOrientedPlanner.java @@ -0,0 +1,216 @@ +/* + * 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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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 groups: [A, B] → [C] → [D]
+ *   (A and B are independent and run in parallel)
+ * 
+ * + *

Supports configurable failure handling via {@link ReplanPolicy}: + * + *

    + *
  • {@link ReplanPolicy.Ignore} — proceed regardless of missing outputs (default) + *
  • {@link ReplanPolicy.FailStop} — halt on first missing output + *
  • {@link ReplanPolicy.Replan} — recompute the remaining plan from current world state + *
+ * + *

Supports pluggable search strategies via {@link SearchStrategy}: backward-chaining DFS ({@link + * DfsSearchStrategy}) or forward A* ({@link AStarSearchStrategy}). + */ +public final class GoalOrientedPlanner implements Planner { + + private static final Logger logger = LoggerFactory.getLogger(GoalOrientedPlanner.class); + + private final String goal; + private final List metadata; + private final SearchStrategy searchStrategy; + private final ReplanPolicy replanPolicy; + // Mutable state — planners are used within a single reactive pipeline and are not thread-safe. + private ImmutableList> executionGroups; + private Map agentNameToOutputKey; + private int cursor; + private int replanCount; + + public GoalOrientedPlanner(String goal, List metadata) { + this(goal, metadata, new DfsSearchStrategy(), new ReplanPolicy.Ignore()); + } + + public GoalOrientedPlanner(String goal, List metadata, boolean validateOutputs) { + this( + goal, + metadata, + new DfsSearchStrategy(), + validateOutputs ? new ReplanPolicy.FailStop() : new ReplanPolicy.Ignore()); + } + + public GoalOrientedPlanner( + String goal, + List metadata, + SearchStrategy searchStrategy, + ReplanPolicy replanPolicy) { + this.goal = goal; + this.metadata = metadata; + this.searchStrategy = searchStrategy; + this.replanPolicy = replanPolicy; + } + + @Override + public void init(PlanningContext context) { + buildPlan(context); + replanCount = 0; + } + + @Override + public Single firstAction(PlanningContext context) { + cursor = 0; + return selectNext(); + } + + @Override + public Single nextAction(PlanningContext context) { + if (cursor > 0 && executionGroups != null) { + List missingOutputs = findMissingOutputs(executionGroups.get(cursor - 1), context); + + if (!missingOutputs.isEmpty()) { + if (replanPolicy instanceof ReplanPolicy.FailStop) { + String message = + "Execution stopped: missing expected outputs from previous group: " + + String.join(", ", missingOutputs); + logger.warn(message); + return Single.just(new PlannerAction.DoneWithResult(message)); + } else if (replanPolicy instanceof ReplanPolicy.Replan replan) { + if (replanCount >= replan.maxAttempts()) { + String message = + "Execution stopped: max replan attempts (" + + replan.maxAttempts() + + ") exhausted. Still missing: " + + String.join(", ", missingOutputs); + logger.warn(message); + return Single.just(new PlannerAction.DoneWithResult(message)); + } + + replanCount++; + logger.info( + "Replanning (attempt {}/{}). Current state keys: {}. Missing outputs: {}", + replanCount, + replan.maxAttempts(), + context.state().keySet(), + missingOutputs); + + try { + buildPlan(context); + } catch (IllegalStateException e) { + String message = "Replanning failed: " + e.getMessage(); + logger.warn(message); + return Single.just(new PlannerAction.DoneWithResult(message)); + } + + if (executionGroups.isEmpty()) { + return Single.just(new PlannerAction.Done()); + } + + logger.info("Replanned execution groups: {}", executionGroupNames()); + } + // ReplanPolicy.Ignore: proceed with current plan + } else { + // Previous group succeeded — reset consecutive replan counter + replanCount = 0; + } + } + return selectNext(); + } + + private void buildPlan(PlanningContext context) { + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList> agentGroups = + searchStrategy.searchGrouped(graph, metadata, context.state().keySet(), goal); + + logger.info("GoalOrientedPlanner resolved execution groups: {}", agentGroups); + + executionGroups = + agentGroups.stream() + .map( + group -> + group.stream().map(context::findAgent).collect(ImmutableList.toImmutableList())) + .collect(ImmutableList.toImmutableList()); + cursor = 0; + + agentNameToOutputKey = new HashMap<>(); + for (AgentMetadata m : metadata) { + agentNameToOutputKey.put(m.agentName(), m.outputKey()); + } + } + + private List findMissingOutputs(ImmutableList group, PlanningContext context) { + List missing = new ArrayList<>(); + for (BaseAgent agent : group) { + String expectedOutput = agentNameToOutputKey.get(agent.name()); + if (expectedOutput != null && !context.state().containsKey(expectedOutput)) { + missing.add(agent.name() + " -> " + expectedOutput); + logger.warn( + "GoalOrientedPlanner: agent '{}' did not produce expected output key '{}'", + agent.name(), + expectedOutput); + } + } + return missing; + } + + private List> executionGroupNames() { + return executionGroups.stream() + .map(group -> group.stream().map(BaseAgent::name).toList()) + .toList(); + } + + private Single selectNext() { + if (executionGroups == null || cursor >= executionGroups.size()) { + return Single.just(new PlannerAction.Done()); + } + ImmutableList group = executionGroups.get(cursor++); + return Single.just(new PlannerAction.RunAgents(group)); + } +} 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..21243c632 --- /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 backward-chaining dependency resolution. + */ +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/goap/ReplanPolicy.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/ReplanPolicy.java new file mode 100644 index 000000000..7cffe593e --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/ReplanPolicy.java @@ -0,0 +1,45 @@ +/* + * 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; + +/** + * Policy governing how the planner reacts to missing expected outputs after an agent group + * executes. + */ +public sealed interface ReplanPolicy + permits ReplanPolicy.FailStop, ReplanPolicy.Replan, ReplanPolicy.Ignore { + + /** Stop immediately on failure with an error message. */ + record FailStop() implements ReplanPolicy {} + + /** + * Attempt to recompute the remaining plan from current world state. + * + * @param maxAttempts maximum number of consecutive replan attempts before falling back to + * fail-stop. Must be {@code >= 1}. + */ + record Replan(int maxAttempts) implements ReplanPolicy { + public Replan { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be >= 1, got " + maxAttempts); + } + } + } + + /** Ignore failures and proceed with the remaining plan as-is. */ + record Ignore() implements ReplanPolicy {} +} diff --git a/contrib/planners/src/main/java/com/google/adk/planner/goap/SearchStrategy.java b/contrib/planners/src/main/java/com/google/adk/planner/goap/SearchStrategy.java new file mode 100644 index 000000000..23734c465 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/goap/SearchStrategy.java @@ -0,0 +1,47 @@ +/* + * 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.List; + +/** + * Strategy for searching a dependency graph to find ordered agent execution groups. + * + *

Given a graph, agent metadata, available preconditions, and a goal output key, produces an + * ordered list of agent groups where agents within each group are independent and can run in + * parallel. + */ +public interface SearchStrategy { + + /** + * Searches for agent execution groups that produce the goal. + * + * @param graph the dependency graph + * @param metadata agent metadata + * @param preconditions state keys already available + * @param goal the target output key + * @return ordered list of agent groups for parallel execution + * @throws IllegalStateException if the goal cannot be reached + */ + ImmutableList> searchGrouped( + GoalOrientedSearchGraph graph, + List metadata, + Collection preconditions, + String goal); +} 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..b52edac6c --- /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.Map; + +/** + * 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(Map 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..79095e0c6 --- /dev/null +++ b/contrib/planners/src/main/java/com/google/adk/planner/p2p/P2PPlanner.java @@ -0,0 +1,173 @@ +/* + * 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.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +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; + // Mutable state — planners are used within a single reactive pipeline and are not thread-safe. + private int invocationCount; + private Map outputValueSnapshot; + + /** + * 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 = 0; + + outputValueSnapshot = new HashMap<>(); + for (AgentMetadata m : metadata) { + Object val = context.state().get(m.outputKey()); + if (val != null) { + outputValueSnapshot.put(m.outputKey(), val); + } + } + } + + @Override + public Single firstAction(PlanningContext context) { + return findReadyAgents(context); + } + + @Override + public Single nextAction(PlanningContext context) { + int count = invocationCount; + + // 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 activators only about output keys whose values have actually changed + for (AgentMetadata m : metadata) { + String key = m.outputKey(); + Object currentValue = context.state().get(key); + if (currentValue != null) { + Object previousValue = outputValueSnapshot.get(key); + if (!Objects.equals(currentValue, previousValue)) { + for (AgentActivator activator : activators.values()) { + activator.onStateChanged(key); + } + outputValueSnapshot.put(key, currentValue); + } + } + } + + return findReadyAgents(context); + } + + private Single findReadyAgents(PlanningContext context) { + if (invocationCount >= 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++; + } + } + + 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/agents/PlannerAgentTest.java b/contrib/planners/src/test/java/com/google/adk/agents/PlannerAgentTest.java new file mode 100644 index 000000000..9e16205b4 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/agents/PlannerAgentTest.java @@ -0,0 +1,324 @@ +/* + * 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.truth.Truth.assertThat; + +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.adk.testing.TestBaseAgent; +import com.google.adk.testing.TestUtils; +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 = TestUtils.createSubAgent("sub", TestUtils.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 = TestUtils.createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).isEmpty(); + } + + @Test + public void runAsync_withDoneWithResult_emitsResultEvent() { + TestBaseAgent subAgent = TestUtils.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 = TestUtils.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 = TestUtils.createEvent("e1"); + TestBaseAgent subAgent = TestUtils.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 = TestUtils.createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).containsExactly(event1); + } + + @Test + public void runAsync_withMaxIterations_stopsAtLimit() { + TestBaseAgent subAgent = + TestUtils.createSubAgent("sub", () -> Flowable.just(TestUtils.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 = TestUtils.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 = TestUtils.createEvent("e1"); + Event event2 = TestUtils.createEvent("e2"); + Event event3 = TestUtils.createEvent("e3"); + TestBaseAgent agentA = TestUtils.createSubAgent("agentA", event1); + TestBaseAgent agentB = TestUtils.createSubAgent("agentB", event2); + TestBaseAgent agentC = TestUtils.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 = TestUtils.createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).containsExactly(event1, event2, event3).inOrder(); + } + + @Test + public void runAsync_withParallelRunAgents_runsMultipleAgents() { + Event event1 = TestUtils.createEvent("e1"); + Event event2 = TestUtils.createEvent("e2"); + TestBaseAgent agentA = TestUtils.createSubAgent("agentA", event1); + TestBaseAgent agentB = TestUtils.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 = TestUtils.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 = TestUtils.createInvocationContext(agent); + List events = agent.runAsync(ctx).toList().blockingGet(); + + assertThat(events).isEmpty(); + } + + @Test(expected = IllegalStateException.class) + public void builder_withoutPlanner_throwsIllegalState() { + TestBaseAgent subAgent = TestUtils.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 = + TestUtils.createEvent("eA").toBuilder() + .actions( + EventActions.builder() + .stateDelta( + new java.util.concurrent.ConcurrentHashMap<>( + java.util.Map.of("key1", "value1"))) + .build()) + .build(); + + TestBaseAgent agentA = TestUtils.createSubAgent("agentA", eventA); + TestBaseAgent agentB = TestUtils.createSubAgent("agentB", TestUtils.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 = TestUtils.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"); + } +} diff --git a/contrib/planners/src/test/java/com/google/adk/planner/LoopPlannerTest.java b/contrib/planners/src/test/java/com/google/adk/planner/LoopPlannerTest.java new file mode 100644 index 000000000..12548950f --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/LoopPlannerTest.java @@ -0,0 +1,187 @@ +/* + * 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 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.events.EventActions; +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.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link LoopPlanner}. */ +class LoopPlannerTest { + + 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_runsFirstAgent() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + LoopPlanner planner = new LoopPlanner(3); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(((PlannerAction.RunAgents) action).agents().get(0).name()).isEqualTo("agentA"); + } + + @Test + void nextAction_cyclesThroughAgents() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + LoopPlanner planner = new LoopPlanner(2); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB), new ConcurrentHashMap<>()); + planner.init(context); + + List executionOrder = new ArrayList<>(); + + PlannerAction action = planner.firstAction(context).blockingGet(); + while (action instanceof PlannerAction.RunAgents runAgents) { + executionOrder.add(runAgents.agents().get(0).name()); + action = planner.nextAction(context).blockingGet(); + } + + // 2 agents x 2 cycles = 4 executions: A, B, A, B + assertThat(executionOrder).containsExactly("agentA", "agentB", "agentA", "agentB").inOrder(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void nextAction_stopsAtMaxCycles() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + LoopPlanner planner = new LoopPlanner(1); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + planner.init(context); + + // First cycle: runs agentA + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + + // Second cycle would exceed maxCycles=1 + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(second).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void nextAction_stopsOnEscalateEvent() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + LoopPlanner planner = new LoopPlanner(10); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + planner.init(context); + + // First action runs normally + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + + // Inject an escalate event into the session + Event escalateEvent = + Event.builder() + .id(Event.generateEventId()) + .invocationId("test-invocation") + .author("test") + .actions(EventActions.builder().escalate(true).build()) + .build(); + context.events().add(escalateEvent); + + // Next action should detect escalate and stop + PlannerAction next = planner.nextAction(context).blockingGet(); + assertThat(next).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void firstAction_withNoAgents_returnsDone() { + LoopPlanner planner = new LoopPlanner(3); + PlanningContext context = createPlanningContext(ImmutableList.of(), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void nextAction_withSingleAgentCycles() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + LoopPlanner planner = new LoopPlanner(3); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + planner.init(context); + + int runCount = 0; + PlannerAction action = planner.firstAction(context).blockingGet(); + while (action instanceof PlannerAction.RunAgents) { + runCount++; + action = planner.nextAction(context).blockingGet(); + } + + // 1 agent x 3 cycles = 3 executions + assertThat(runCount).isEqualTo(3); + 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/contrib/planners/src/test/java/com/google/adk/planner/ParallelPlannerTest.java b/contrib/planners/src/test/java/com/google/adk/planner/ParallelPlannerTest.java new file mode 100644 index 000000000..c9cd2578a --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/ParallelPlannerTest.java @@ -0,0 +1,127 @@ +/* + * 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 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.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 ParallelPlanner}. */ +class ParallelPlannerTest { + + 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_runsAllAgentsInParallel() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + SimpleTestAgent agentC = new SimpleTestAgent("agentC"); + + ParallelPlanner planner = new ParallelPlanner(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC), new ConcurrentHashMap<>()); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + PlannerAction.RunAgents runAgents = (PlannerAction.RunAgents) action; + assertThat(runAgents.agents()).hasSize(3); + List names = runAgents.agents().stream().map(BaseAgent::name).toList(); + assertThat(names).containsExactly("agentA", "agentB", "agentC"); + } + + @Test + void nextAction_returnsDone() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + ParallelPlanner planner = new ParallelPlanner(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB), new ConcurrentHashMap<>()); + + planner.firstAction(context).blockingGet(); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void firstAction_withEmptyAgents_returnsDone() { + ParallelPlanner planner = new ParallelPlanner(); + PlanningContext context = createPlanningContext(ImmutableList.of(), new ConcurrentHashMap<>()); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void firstAction_withSingleAgent_runsIt() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + ParallelPlanner planner = new ParallelPlanner(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), 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"); + } + + 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/SequentialPlannerTest.java b/contrib/planners/src/test/java/com/google/adk/planner/SequentialPlannerTest.java new file mode 100644 index 000000000..573c634a7 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/SequentialPlannerTest.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.planner; + +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.sessions.InMemorySessionService; +import com.google.adk.sessions.Session; +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Flowable; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link SequentialPlanner}. */ +class SequentialPlannerTest { + + 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_runsFirstAgent() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + SequentialPlanner planner = new SequentialPlanner(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + 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_runsAgentsInOrder() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + SimpleTestAgent agentC = new SimpleTestAgent("agentC"); + + SequentialPlanner planner = new SequentialPlanner(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(((PlannerAction.RunAgents) first).agents().get(0).name()).isEqualTo("agentA"); + + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(((PlannerAction.RunAgents) second).agents().get(0).name()).isEqualTo("agentB"); + + PlannerAction third = planner.nextAction(context).blockingGet(); + assertThat(((PlannerAction.RunAgents) third).agents().get(0).name()).isEqualTo("agentC"); + } + + @Test + void nextAction_returnsDoneAfterAll() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + SequentialPlanner planner = new SequentialPlanner(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB), new ConcurrentHashMap<>()); + planner.init(context); + + planner.firstAction(context).blockingGet(); + planner.nextAction(context).blockingGet(); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void firstAction_withNoAgents_returnsDone() { + SequentialPlanner planner = new SequentialPlanner(); + PlanningContext context = createPlanningContext(ImmutableList.of(), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void init_resetsCursor() { + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + SimpleTestAgent agentB = new SimpleTestAgent("agentB"); + + SequentialPlanner planner = new SequentialPlanner(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB), new ConcurrentHashMap<>()); + planner.init(context); + + // Exhaust the planner + planner.firstAction(context).blockingGet(); + planner.nextAction(context).blockingGet(); + PlannerAction exhausted = planner.nextAction(context).blockingGet(); + assertThat(exhausted).isInstanceOf(PlannerAction.Done.class); + + // Re-init and verify cursor resets + planner.init(context); + PlannerAction restarted = planner.firstAction(context).blockingGet(); + assertThat(restarted).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(((PlannerAction.RunAgents) restarted).agents().get(0).name()).isEqualTo("agentA"); + } + + 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/SupervisorPlannerTest.java b/contrib/planners/src/test/java/com/google/adk/planner/SupervisorPlannerTest.java new file mode 100644 index 000000000..5accfc743 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/SupervisorPlannerTest.java @@ -0,0 +1,233 @@ +/* + * 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.times; +import static org.mockito.Mockito.verify; +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.LlmRequest; +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; +import org.mockito.ArgumentCaptor; + +/** 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); + } + + @Test + void buildPrompt_includesDecisionHistory() { + BaseLlm mockLlm = mock(BaseLlm.class); + + LlmResponse response1 = createTextResponse("agentA"); + LlmResponse response2 = createTextResponse("DONE"); + when(mockLlm.generateContent(any(), eq(false))) + .thenReturn(Flowable.just(response1)) + .thenReturn(Flowable.just(response2)); + + SimpleTestAgent agentA = new SimpleTestAgent("agentA"); + + SupervisorPlanner planner = new SupervisorPlanner(mockLlm, "You are a supervisor.", 2); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA), new ConcurrentHashMap<>()); + + planner.firstAction(context).blockingGet(); + planner.nextAction(context).blockingGet(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(LlmRequest.class); + verify(mockLlm, times(2)).generateContent(requestCaptor.capture(), eq(false)); + + LlmRequest secondRequest = requestCaptor.getAllValues().get(1); + String promptText = secondRequest.contents().get(0).parts().get().get(0).text().get(); + assertThat(promptText).contains("Previous decisions"); + assertThat(promptText).contains("Run: agentA"); + } + + @Test + void decisionHistory_accumulatesAcrossCalls() { + BaseLlm mockLlm = mock(BaseLlm.class); + + LlmResponse response1 = createTextResponse("agentA"); + LlmResponse response2 = createTextResponse("agentB"); + LlmResponse response3 = createTextResponse("DONE"); + when(mockLlm.generateContent(any(), eq(false))) + .thenReturn(Flowable.just(response1)) + .thenReturn(Flowable.just(response2)) + .thenReturn(Flowable.just(response3)); + + 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<>()); + + planner.firstAction(context).blockingGet(); + planner.nextAction(context).blockingGet(); + planner.nextAction(context).blockingGet(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(LlmRequest.class); + verify(mockLlm, times(3)).generateContent(requestCaptor.capture(), eq(false)); + + LlmRequest thirdRequest = requestCaptor.getAllValues().get(2); + String promptText = thirdRequest.contents().get(0).parts().get().get(0).text().get(); + assertThat(promptText).contains("1. Run: agentA"); + assertThat(promptText).contains("2. Run: agentB"); + } + + 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/AStarSearchStrategyTest.java b/contrib/planners/src/test/java/com/google/adk/planner/goap/AStarSearchStrategyTest.java new file mode 100644 index 000000000..78dc519f1 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/goap/AStarSearchStrategyTest.java @@ -0,0 +1,295 @@ +/* + * 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.common.collect.ImmutableList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AStarSearchStrategy}. */ +class AStarSearchStrategyTest { + + private final AStarSearchStrategy astar = new AStarSearchStrategy(); + + // ── A. Graph topology tests (mirror DFS tests) ────────────────────────── + + @Test + void linearChain_producesCorrectGroups() { + 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> groups = + astar.searchGrouped(graph, metadata, Set.of(), "outputC"); + + assertThat(groups).hasSize(3); + assertThat(groups.get(0)).containsExactly("agentA"); + assertThat(groups.get(1)).containsExactly("agentB"); + assertThat(groups.get(2)).containsExactly("agentC"); + } + + @Test + void multipleInputs_groupsIndependentAgents() { + 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> groups = + astar.searchGrouped(graph, metadata, Set.of(), "outputC"); + + assertThat(groups).hasSize(2); + assertThat(groups.get(0)).containsExactly("agentA", "agentB"); + assertThat(groups.get(1)).containsExactly("agentC"); + } + + @Test + void diamondDependency_correctGrouping() { + 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> groups = + astar.searchGrouped(graph, metadata, Set.of(), "outputD"); + + assertThat(groups).hasSize(3); + assertThat(groups.get(0)).containsExactly("agentA"); + assertThat(groups.get(1)).containsExactly("agentB", "agentC"); + assertThat(groups.get(2)).containsExactly("agentD"); + } + + @Test + void skipsSatisfiedPreconditions() { + 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> groups = + astar.searchGrouped(graph, metadata, Set.of("outputA"), "outputC"); + + // agentA skipped; only agentB and agentC needed + assertThat(groups).hasSize(2); + assertThat(groups.get(0)).containsExactly("agentB"); + assertThat(groups.get(1)).containsExactly("agentC"); + } + + @Test + void goalAlreadyInPreconditions_returnsEmpty() { + List metadata = + List.of(new AgentMetadata("agentA", ImmutableList.of(), "outputA")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList> groups = + astar.searchGrouped(graph, metadata, Set.of("outputA"), "outputA"); + + assertThat(groups).isEmpty(); + } + + @Test + void throwsOnUnresolvableDependency() { + List metadata = + List.of(new AgentMetadata("agentB", ImmutableList.of("missing"), "outputB")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + + IllegalStateException ex = + assertThrows( + IllegalStateException.class, + () -> astar.searchGrouped(graph, metadata, Set.of(), "outputB")); + assertThat(ex.getMessage()).contains("outputB"); + } + + @Test + void detectsUnreachableGoal_cycle() { + // A needs B's output, B needs A's output — neither can activate + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of("outputB"), "outputA"), + new AgentMetadata("agentB", ImmutableList.of("outputA"), "outputB")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + + assertThrows( + IllegalStateException.class, + () -> astar.searchGrouped(graph, metadata, Set.of(), "outputA")); + } + + // ── B. A*-specific topology tests ─────────────────────────────────────── + + @Test + void singleAgentNoInputs() { + List metadata = + List.of(new AgentMetadata("agentA", ImmutableList.of(), "outputA")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList> groups = + astar.searchGrouped(graph, metadata, Set.of(), "outputA"); + + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).containsExactly("agentA"); + } + + @Test + void wideGraph_allIndependent() { + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of(), "a"), + new AgentMetadata("agentB", ImmutableList.of(), "b"), + new AgentMetadata("agentC", ImmutableList.of(), "c"), + new AgentMetadata("agentD", ImmutableList.of(), "d"), + new AgentMetadata("agentE", ImmutableList.of("a", "b", "c", "d"), "goal")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList> groups = + astar.searchGrouped(graph, metadata, Set.of(), "goal"); + + assertThat(groups).hasSize(2); + assertThat(groups.get(0)).containsExactly("agentA", "agentB", "agentC", "agentD"); + assertThat(groups.get(1)).containsExactly("agentE"); + } + + @Test + void deepChain_fiveLinks() { + List metadata = + List.of( + new AgentMetadata("a1", ImmutableList.of(), "o1"), + new AgentMetadata("a2", ImmutableList.of("o1"), "o2"), + new AgentMetadata("a3", ImmutableList.of("o2"), "o3"), + new AgentMetadata("a4", ImmutableList.of("o3"), "o4"), + new AgentMetadata("a5", ImmutableList.of("o4"), "o5")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList> groups = + astar.searchGrouped(graph, metadata, Set.of(), "o5"); + + assertThat(groups).hasSize(5); + assertThat(groups.get(0)).containsExactly("a1"); + assertThat(groups.get(1)).containsExactly("a2"); + assertThat(groups.get(2)).containsExactly("a3"); + assertThat(groups.get(3)).containsExactly("a4"); + assertThat(groups.get(4)).containsExactly("a5"); + } + + @Test + void complexSixAgentGraph() { + // A:[]→a, B:[]→b, C:[a]→c, D:[b]→d, E:[c,d]→e, F:[a,e]→goal + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of(), "b"), + new AgentMetadata("C", ImmutableList.of("a"), "c"), + new AgentMetadata("D", ImmutableList.of("b"), "d"), + new AgentMetadata("E", ImmutableList.of("c", "d"), "e"), + new AgentMetadata("F", ImmutableList.of("a", "e"), "goal")); + + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + ImmutableList> groups = + astar.searchGrouped(graph, metadata, Set.of(), "goal"); + + assertThat(groups).hasSize(4); + assertThat(groups.get(0)).containsExactly("A", "B"); + assertThat(groups.get(1)).containsExactly("C", "D"); + assertThat(groups.get(2)).containsExactly("E"); + assertThat(groups.get(3)).containsExactly("F"); + } + + // ── C. Cross-strategy equivalence tests ───────────────────────────────── + + @Test + void equivalentToDfs_linearChain() { + List metadata = + List.of( + new AgentMetadata("agentA", ImmutableList.of(), "outputA"), + new AgentMetadata("agentB", ImmutableList.of("outputA"), "outputB"), + new AgentMetadata("agentC", ImmutableList.of("outputB"), "outputC")); + + assertStrategiesEquivalent(metadata, Set.of(), "outputC"); + } + + @Test + void equivalentToDfs_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")); + + assertStrategiesEquivalent(metadata, Set.of(), "outputD"); + } + + @Test + void equivalentToDfs_horoscope() { + 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")); + + assertStrategiesEquivalent(metadata, Set.of("prompt"), "writeup"); + } + + @Test + void equivalentToDfs_complexSixAgent() { + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of(), "b"), + new AgentMetadata("C", ImmutableList.of("a"), "c"), + new AgentMetadata("D", ImmutableList.of("b"), "d"), + new AgentMetadata("E", ImmutableList.of("c", "d"), "e"), + new AgentMetadata("F", ImmutableList.of("a", "e"), "goal")); + + assertStrategiesEquivalent(metadata, Set.of(), "goal"); + } + + // ── Helper ────────────────────────────────────────────────────────────── + + private void assertStrategiesEquivalent( + List metadata, Set preconditions, String goal) { + GoalOrientedSearchGraph graph = new GoalOrientedSearchGraph(metadata); + + DfsSearchStrategy dfs = new DfsSearchStrategy(); + ImmutableList> dfsGroups = + dfs.searchGrouped(graph, metadata, preconditions, goal); + ImmutableList> astarGroups = + astar.searchGrouped(graph, metadata, preconditions, goal); + + assertThat(astarGroups).hasSize(dfsGroups.size()); + for (int i = 0; i < dfsGroups.size(); i++) { + assertThat(astarGroups.get(i)).containsExactlyElementsIn(dfsGroups.get(i)); + } + } +} diff --git a/contrib/planners/src/test/java/com/google/adk/planner/goap/GoapLlmCouncilTopologyTest.java b/contrib/planners/src/test/java/com/google/adk/planner/goap/GoapLlmCouncilTopologyTest.java new file mode 100644 index 000000000..3ed8824c3 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/goap/GoapLlmCouncilTopologyTest.java @@ -0,0 +1,967 @@ +/* + * 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.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link GoalOrientedPlanner} against a realistic council-like 9-agent pipeline. Models the + * LLM Council's dependency graph using ADK's string-based {@link AgentMetadata}. + * + *

Council topology: + * + *

+ *   initial_response → peer_ranking ──────────→ final_synthesis ─┐
+ *                   → agreement_analysis ──→ aggregate_agreements │→ council_summary
+ *                   → disagreement_analysis → aggregate_disagreements │
+ *                      peer_ranking → aggregate_rankings ─────────┘
+ * 
+ */ +class GoapLlmCouncilTopologyTest { + + // ── Council-like metadata ────────────────────────────────────────────── + + static final List COUNCIL_METADATA = + List.of( + new AgentMetadata("initial_response", ImmutableList.of(), "individual_responses"), + new AgentMetadata( + "peer_ranking", ImmutableList.of("individual_responses"), "peer_rankings"), + new AgentMetadata( + "agreement_analysis", ImmutableList.of("individual_responses"), "agreement_analyses"), + new AgentMetadata( + "disagreement_analysis", + ImmutableList.of("individual_responses"), + "disagreement_analyses"), + new AgentMetadata( + "final_synthesis", + ImmutableList.of("individual_responses", "peer_rankings"), + "final_synthesis"), + new AgentMetadata( + "aggregate_rankings", ImmutableList.of("peer_rankings"), "aggregate_rankings"), + new AgentMetadata( + "aggregate_agreements", + ImmutableList.of("agreement_analyses"), + "aggregate_agreements"), + new AgentMetadata( + "aggregate_disagreements", + ImmutableList.of("disagreement_analyses"), + "aggregate_disagreements"), + new AgentMetadata( + "council_summary", + ImmutableList.of( + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements"), + "council_summary")); + + static final ImmutableList ALL_AGENT_NAMES = + ImmutableList.of( + "initial_response", + "peer_ranking", + "agreement_analysis", + "disagreement_analysis", + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements", + "council_summary"); + + // ── Test infrastructure ──────────────────────────────────────────────── + + 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(); + } + } + + private static ImmutableList councilAgents() { + return ALL_AGENT_NAMES.stream() + .map(SimpleTestAgent::new) + .collect(ImmutableList.toImmutableList()); + } + + private static PlanningContext createPlanningContext( + ImmutableList agents, ConcurrentHashMap state) { + 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); + } + + private static List agentNames(PlannerAction.RunAgents action) { + return action.agents().stream().map(BaseAgent::name).toList(); + } + + /** Collects all execution groups by walking firstAction/nextAction until Done. */ + private static List> collectAllGroups( + GoalOrientedPlanner planner, PlanningContext context) { + List> groups = new ArrayList<>(); + PlannerAction action = planner.firstAction(context).blockingGet(); + while (action instanceof PlannerAction.RunAgents run) { + groups.add(agentNames(run)); + action = planner.nextAction(context).blockingGet(); + } + return groups; + } + + // ── Part 1: GoapPlanningBehavior ─────────────────────────────────────────── + + @Nested + class GoapPlanningBehavior { + + @Test + void fullCouncilProducesFourGroups() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("council_summary", COUNCIL_METADATA); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> groups = collectAllGroups(planner, context); + + assertThat(groups).hasSize(4); + assertThat(groups.get(0)).hasSize(1); + assertThat(groups.get(1)).hasSize(3); + assertThat(groups.get(2)).hasSize(4); + assertThat(groups.get(3)).hasSize(1); + } + + @Test + void synthesisGoalProducesThreeGroups() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("final_synthesis", COUNCIL_METADATA); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> groups = collectAllGroups(planner, context); + + assertThat(groups).hasSize(3); + assertThat(groups.get(0)).containsExactly("initial_response"); + assertThat(groups.get(1)).containsExactly("peer_ranking"); + assertThat(groups.get(2)).containsExactly("final_synthesis"); + } + + @Test + void rankingsGoalProducesThreeGroups() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("aggregate_rankings", COUNCIL_METADATA); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> groups = collectAllGroups(planner, context); + + assertThat(groups).hasSize(3); + assertThat(groups.get(0)).containsExactly("initial_response"); + assertThat(groups.get(1)).containsExactly("peer_ranking"); + assertThat(groups.get(2)).containsExactly("aggregate_rankings"); + } + + @Test + void agreementGoalExcludesDisagreementPipeline() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner("aggregate_agreements", COUNCIL_METADATA); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> groups = collectAllGroups(planner, context); + + assertThat(groups).hasSize(3); + List allAgents = groups.stream().flatMap(List::stream).toList(); + assertThat(allAgents).doesNotContain("disagreement_analysis"); + assertThat(allAgents).doesNotContain("aggregate_disagreements"); + assertThat(allAgents).doesNotContain("peer_ranking"); + assertThat(allAgents).contains("agreement_analysis"); + assertThat(allAgents).contains("aggregate_agreements"); + } + + @Test + void fullCouncilParallelGrouping() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("council_summary", COUNCIL_METADATA); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).containsExactly("initial_response"); + + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(second).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) second)) + .containsExactly("peer_ranking", "agreement_analysis", "disagreement_analysis"); + + PlannerAction third = planner.nextAction(context).blockingGet(); + assertThat(third).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) third)) + .containsExactly( + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements"); + + PlannerAction fourth = planner.nextAction(context).blockingGet(); + assertThat(fourth).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) fourth)).containsExactly("council_summary"); + } + + @Test + void fullCouncilCompletesWithDone() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("council_summary", COUNCIL_METADATA); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Walk all 4 groups + PlannerAction action = planner.firstAction(context).blockingGet(); + for (int i = 0; i < 3; i++) { + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + action = planner.nextAction(context).blockingGet(); + } + // 4th group + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + + // Final: Done + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void aStarAndDfsProduceEquivalentGroups() { + ConcurrentHashMap state = new ConcurrentHashMap<>(); + + GoalOrientedPlanner dfsPlanner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Ignore()); + PlanningContext dfsCtx = createPlanningContext(councilAgents(), state); + dfsPlanner.init(dfsCtx); + List> dfsGroups = collectAllGroups(dfsPlanner, dfsCtx); + + GoalOrientedPlanner aStarPlanner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new AStarSearchStrategy(), + new ReplanPolicy.Ignore()); + PlanningContext aStarCtx = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + aStarPlanner.init(aStarCtx); + List> aStarGroups = collectAllGroups(aStarPlanner, aStarCtx); + + assertThat(aStarGroups).hasSize(dfsGroups.size()); + for (int i = 0; i < dfsGroups.size(); i++) { + assertThat(aStarGroups.get(i)).containsExactlyElementsIn(dfsGroups.get(i)); + } + } + + @Test + void preconditionSkipsInitialResponse() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("council_summary", COUNCIL_METADATA); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("individual_responses", "already available"); + + PlanningContext context = createPlanningContext(councilAgents(), state); + planner.init(context); + + List> groups = collectAllGroups(planner, context); + + // initial_response should be skipped + assertThat(groups).hasSize(3); + List allAgents = groups.stream().flatMap(List::stream).toList(); + assertThat(allAgents).doesNotContain("initial_response"); + } + + @Test + void goalAlreadySatisfied_returnsEmptyPlan() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("council_summary", COUNCIL_METADATA); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("council_summary", "already done"); + + PlanningContext context = createPlanningContext(councilAgents(), state); + planner.init(context); + + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void defaultConstructorUsesIgnore() { + GoalOrientedPlanner planner = new GoalOrientedPlanner("council_summary", COUNCIL_METADATA); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response + planner.firstAction(context).blockingGet(); + // Don't put "individual_responses" → agent failed + + // Ignore policy: proceeds to next group regardless + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).hasSize(3); + } + } + + // ── Part 2: AdaptiveGoapReplanning ───────────────────────────────────────── + + @Nested + class AdaptiveGoapReplanning { + + @Test + void initialResponseFails_replanRetries() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response + planner.firstAction(context).blockingGet(); + // initial_response fails — don't add output + + // Replan: from {} → same plan, runs initial_response again + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("initial_response"); + } + + @Test + void parallelGroupPartialFailure_replanWithPartialState() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: [peer_ranking, agreement_analysis, disagreement_analysis] + PlannerAction group2 = planner.nextAction(context).blockingGet(); + assertThat(group2).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) group2)).hasSize(3); + + // peer_ranking + agreement succeed, disagreement fails + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + + // Replan from {individual_responses, peer_rankings, agreement_analyses}: + // All agents whose preconditions are now satisfied run in one group + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + List replanAgents = agentNames((PlannerAction.RunAgents) replan); + assertThat(replanAgents).contains("disagreement_analysis"); + assertThat(replanAgents).doesNotContain("initial_response"); + assertThat(replanAgents).doesNotContain("peer_ranking"); + } + + @Test + void aggregationFails_replanOnlyAggregation() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: all succeed + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + // Group 3: [final_synthesis, aggregate_rankings, aggregate_agreements, + // aggregate_disagreements] + PlannerAction group3 = planner.nextAction(context).blockingGet(); + assertThat(group3).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) group3)).hasSize(4); + + // All succeed except aggregate_disagreements + context.state().put("final_synthesis", "done"); + context.state().put("aggregate_rankings", "done"); + context.state().put("aggregate_agreements", "done"); + + // Replan: only aggregate_disagreements + council_summary remain + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) replan)) + .containsExactly("aggregate_disagreements"); + } + + @Test + void multipleRetriesWithProgressiveState() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(3)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: all 3 agents fail + planner.nextAction(context).blockingGet(); + + // Replan 1: runs same 3 agents again + PlannerAction replan1 = planner.nextAction(context).blockingGet(); + assertThat(replan1).isInstanceOf(PlannerAction.RunAgents.class); + + // Only peer_ranking succeeds this time + context.state().put("peer_rankings", "done"); + + // Replan 2: from {individual_responses, peer_rankings} → + // remaining: agreement_analysis, disagreement_analysis + PlannerAction replan2 = planner.nextAction(context).blockingGet(); + assertThat(replan2).isInstanceOf(PlannerAction.RunAgents.class); + List replan2Agents = agentNames((PlannerAction.RunAgents) replan2); + assertThat(replan2Agents).containsAtLeast("agreement_analysis", "disagreement_analysis"); + } + + @Test + void maxAttemptsExhaustedInCouncilPipeline() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // initial_response runs, fails repeatedly + planner.firstAction(context).blockingGet(); + + // Replan 1: retry + PlannerAction r1 = planner.nextAction(context).blockingGet(); + assertThat(r1).isInstanceOf(PlannerAction.RunAgents.class); + + // Replan 2: retry + PlannerAction r2 = planner.nextAction(context).blockingGet(); + assertThat(r2).isInstanceOf(PlannerAction.RunAgents.class); + + // Replan 3: exhausted (count=2 >= max=2) + PlannerAction exhausted = planner.nextAction(context).blockingGet(); + assertThat(exhausted).isInstanceOf(PlannerAction.DoneWithResult.class); + assertThat(((PlannerAction.DoneWithResult) exhausted).result()) + .contains("max replan attempts"); + } + + @Test + void counterResetsAfterSuccessfulCouncilStage() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response fails + planner.firstAction(context).blockingGet(); + + // Replan (count=1): retry + PlannerAction replan1 = planner.nextAction(context).blockingGet(); + assertThat(replan1).isInstanceOf(PlannerAction.RunAgents.class); + + // initial_response succeeds → counter resets + context.state().put("individual_responses", "done"); + + // Group 2 proceeds + PlannerAction group2 = planner.nextAction(context).blockingGet(); + assertThat(group2).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) group2)).hasSize(3); + + // Group 2 fails → new replan allowed (count was reset to 0) + PlannerAction replan2 = planner.nextAction(context).blockingGet(); + assertThat(replan2).isInstanceOf(PlannerAction.RunAgents.class); + } + + @Test + void replanWithDfsStrategy() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(1)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1 succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: partial failure (only peer_ranking succeeds) + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + + // DFS replan from {individual_responses, peer_rankings} + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + List agents = agentNames((PlannerAction.RunAgents) replan); + assertThat(agents).containsAtLeast("agreement_analysis", "disagreement_analysis"); + assertThat(agents).doesNotContain("initial_response"); + assertThat(agents).doesNotContain("peer_ranking"); + } + + @Test + void replanWithAStarStrategy() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new AStarSearchStrategy(), + new ReplanPolicy.Replan(1)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1 succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: partial failure (only peer_ranking succeeds) + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + + // A* replan from {individual_responses, peer_rankings} + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + List agents = agentNames((PlannerAction.RunAgents) replan); + assertThat(agents).containsAtLeast("agreement_analysis", "disagreement_analysis"); + assertThat(agents).doesNotContain("initial_response"); + assertThat(agents).doesNotContain("peer_ranking"); + } + + @Test + void goalExternallySatisfiedDuringReplan() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(1)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response fails + planner.firstAction(context).blockingGet(); + + // Goal satisfied externally + context.state().put("council_summary", "external_result"); + + // Replan: goal is in preconditions → empty plan → Done + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void failStopOnCouncilPipeline() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.FailStop()); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1 succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: all 3 agents fail + planner.nextAction(context).blockingGet(); + + // FailStop: DoneWithResult + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.DoneWithResult.class); + String result = ((PlannerAction.DoneWithResult) action).result(); + assertThat(result).contains("peer_ranking"); + assertThat(result).contains("peer_rankings"); + } + + @Test + void fullCouncilSuccessfulReplanThenCompletion() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1: initial_response succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: only peer_ranking succeeds + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + + // Replan: agreement + disagreement needed + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + + // Both succeed now + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + // Next group: [final_synthesis, aggregate_rankings, aggregate_agreements, + // aggregate_disagreements] + PlannerAction group3 = planner.nextAction(context).blockingGet(); + assertThat(group3).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) group3)).hasSize(4); + + // All succeed + context.state().put("final_synthesis", "done"); + context.state().put("aggregate_rankings", "done"); + context.state().put("aggregate_agreements", "done"); + context.state().put("aggregate_disagreements", "done"); + + // Next: council_summary + PlannerAction summary = planner.nextAction(context).blockingGet(); + assertThat(summary).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) summary)).containsExactly("council_summary"); + + // council_summary succeeds + context.state().put("council_summary", "done"); + + // Final: Done + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + } + + // ── Part 3: EdgeCases ────────────────────────────────────────────────── + + @Nested + class EdgeCases { + + @Test + void allParallelAgentsFail_fullGroupReplan() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(1)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1 succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: [peer_ranking, agreement_analysis, disagreement_analysis] + planner.nextAction(context).blockingGet(); + // All 3 fail — no state added + + // Replan from {individual_responses}: same 3 agents needed again + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) replan)) + .containsExactly("peer_ranking", "agreement_analysis", "disagreement_analysis"); + } + + @Test + void policyComparison_councilFailure() { + ImmutableList agents = councilAgents(); + + // Same scenario: group 1 succeeds, group 2 all fail + + // FailStop → DoneWithResult + GoalOrientedPlanner failStopPlanner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.FailStop()); + ConcurrentHashMap state1 = new ConcurrentHashMap<>(); + PlanningContext ctx1 = createPlanningContext(agents, state1); + failStopPlanner.init(ctx1); + failStopPlanner.firstAction(ctx1).blockingGet(); + ctx1.state().put("individual_responses", "done"); + failStopPlanner.nextAction(ctx1).blockingGet(); + PlannerAction failStopResult = failStopPlanner.nextAction(ctx1).blockingGet(); + assertThat(failStopResult).isInstanceOf(PlannerAction.DoneWithResult.class); + + // Ignore → RunAgents (proceeds to group 3) + GoalOrientedPlanner ignorePlanner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Ignore()); + ConcurrentHashMap state2 = new ConcurrentHashMap<>(); + PlanningContext ctx2 = createPlanningContext(agents, state2); + ignorePlanner.init(ctx2); + ignorePlanner.firstAction(ctx2).blockingGet(); + ctx2.state().put("individual_responses", "done"); + ignorePlanner.nextAction(ctx2).blockingGet(); + PlannerAction ignoreResult = ignorePlanner.nextAction(ctx2).blockingGet(); + assertThat(ignoreResult).isInstanceOf(PlannerAction.RunAgents.class); + // Proceeds to group 3 (4 agents) + assertThat(agentNames((PlannerAction.RunAgents) ignoreResult)).hasSize(4); + + // Replan → RunAgents (retries failed agents) + GoalOrientedPlanner replanPlanner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(1)); + ConcurrentHashMap state3 = new ConcurrentHashMap<>(); + PlanningContext ctx3 = createPlanningContext(agents, state3); + replanPlanner.init(ctx3); + replanPlanner.firstAction(ctx3).blockingGet(); + ctx3.state().put("individual_responses", "done"); + replanPlanner.nextAction(ctx3).blockingGet(); + PlannerAction replanResult = replanPlanner.nextAction(ctx3).blockingGet(); + assertThat(replanResult).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) replanResult)) + .containsExactly("peer_ranking", "agreement_analysis", "disagreement_analysis"); + } + + @Test + void largeParallelGroupMultipleFailures() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Groups 1-2 succeed + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + // Group 3 (4 agents): final_synthesis + aggregate_rankings succeed, + // aggregate_agreements + aggregate_disagreements fail + planner.nextAction(context).blockingGet(); + context.state().put("final_synthesis", "done"); + context.state().put("aggregate_rankings", "done"); + + // Replan from state with 2 of 4 outputs missing + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + List replanAgents = agentNames((PlannerAction.RunAgents) replan); + assertThat(replanAgents).containsAtLeast("aggregate_agreements", "aggregate_disagreements"); + assertThat(replanAgents).doesNotContain("final_synthesis"); + assertThat(replanAgents).doesNotContain("aggregate_rankings"); + } + + @Test + void replanReducesToEmptyPlanWhenGoalSatisfied() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(1)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1 fails + planner.firstAction(context).blockingGet(); + + // Goal satisfied externally before replan + context.state().put("council_summary", "injected"); + + // Replan: goal in state → empty plan → Done + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void reinitWithDifferentGoal() { + ImmutableList agents = councilAgents(); + + // First init: full council (9 agents, 4 groups) + GoalOrientedPlanner planner = new GoalOrientedPlanner("council_summary", COUNCIL_METADATA); + ConcurrentHashMap state1 = new ConcurrentHashMap<>(); + PlanningContext ctx1 = createPlanningContext(agents, state1); + planner.init(ctx1); + + List> fullGroups = collectAllGroups(planner, ctx1); + assertThat(fullGroups).hasSize(4); + + // Second init: rankings only (3 agents, 3 groups) + GoalOrientedPlanner planner2 = + new GoalOrientedPlanner("aggregate_rankings", COUNCIL_METADATA); + ConcurrentHashMap state2 = new ConcurrentHashMap<>(); + PlanningContext ctx2 = createPlanningContext(agents, state2); + planner2.init(ctx2); + + List> rankingGroups = collectAllGroups(planner2, ctx2); + assertThat(rankingGroups).hasSize(3); + + List allAgents = rankingGroups.stream().flatMap(List::stream).toList(); + assertThat(allAgents) + .containsExactly("initial_response", "peer_ranking", "aggregate_rankings"); + } + + @Test + void failStopMessageContainsSpecificMissingOutputs() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.FailStop()); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Group 1 succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Group 2: only agreement_analysis succeeds + planner.nextAction(context).blockingGet(); + context.state().put("agreement_analyses", "done"); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.DoneWithResult.class); + String msg = ((PlannerAction.DoneWithResult) action).result(); + // Message should contain the failed agents and their expected output keys + assertThat(msg).contains("peer_ranking"); + assertThat(msg).contains("peer_rankings"); + assertThat(msg).contains("disagreement_analysis"); + assertThat(msg).contains("disagreement_analyses"); + // Should NOT mention the agent that succeeded (use arrow format to avoid substring match) + assertThat(msg).doesNotContain("agreement_analysis -> agreement_analyses"); + } + + @Test + void deepChainPartialFailureCascade() { + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new DfsSearchStrategy(), + new ReplanPolicy.Replan(2)); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Groups 1-2 succeed fully + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + // Group 3: only final_synthesis fails, rest succeed + planner.nextAction(context).blockingGet(); + context.state().put("aggregate_rankings", "done"); + context.state().put("aggregate_agreements", "done"); + context.state().put("aggregate_disagreements", "done"); + + // Replan: only final_synthesis + council_summary needed + PlannerAction replan = planner.nextAction(context).blockingGet(); + assertThat(replan).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) replan)).containsExactly("final_synthesis"); + } + + @Test + void replanPolicyValidation() { + assertThrows(IllegalArgumentException.class, () -> new ReplanPolicy.Replan(0)); + assertThrows(IllegalArgumentException.class, () -> new ReplanPolicy.Replan(-1)); + } + + @Test + void aStarReplanOnCouncilWithSatisfiedPreconditions() { + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("individual_responses", "pre-existing"); + state.put("peer_rankings", "pre-existing"); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "council_summary", + COUNCIL_METADATA, + new AStarSearchStrategy(), + new ReplanPolicy.Replan(1)); + PlanningContext context = createPlanningContext(councilAgents(), state); + planner.init(context); + + // Group 1 should skip initial_response and peer_ranking + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + List firstAgents = agentNames((PlannerAction.RunAgents) first); + assertThat(firstAgents).doesNotContain("initial_response"); + assertThat(firstAgents).doesNotContain("peer_ranking"); + + // Only agreement + disagreement agents should run (they need individual_responses) + assertThat(firstAgents).containsAtLeast("agreement_analysis", "disagreement_analysis"); + } + } +} diff --git a/contrib/planners/src/test/java/com/google/adk/planner/goap/ReplanningTest.java b/contrib/planners/src/test/java/com/google/adk/planner/goap/ReplanningTest.java new file mode 100644 index 000000000..c6b4e0574 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/goap/ReplanningTest.java @@ -0,0 +1,615 @@ +/* + * 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; + +/** Tests for adaptive replanning in {@link GoalOrientedPlanner} with {@link ReplanPolicy}. */ +class ReplanningTest { + + 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(); + } + } + + // ── A. Core replanning scenarios ──────────────────────────────────────── + + @Test + void replan_partialGroupFailure_recomputesShorterPlan() { + // A:[]→a, B:[]→b, C:[a,b]→goal. Plan: [[A,B],[C]] + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + SimpleTestAgent agentC = new SimpleTestAgent("C"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of(), "b"), + new AgentMetadata("C", ImmutableList.of("a", "b"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(2)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC), state); + planner.init(context); + + // First action: [A, B] in parallel + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).containsExactly("A", "B"); + + // A succeeds, B fails + context.state().put("a", "value_a"); + + // nextAction triggers replan from {"a"} → new plan: [[B],[C]] + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(second).isInstanceOf(PlannerAction.RunAgents.class); + // Replanned: B needs to run (only B, not A since "a" is already available) + assertThat(agentNames((PlannerAction.RunAgents) second)).containsExactly("B"); + + // B succeeds now + context.state().put("b", "value_b"); + + PlannerAction third = planner.nextAction(context).blockingGet(); + assertThat(third).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) third)).containsExactly("C"); + + context.state().put("goal", "done"); + PlannerAction fourth = planner.nextAction(context).blockingGet(); + assertThat(fourth).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void replan_allAgentsInGroupFail_fullReplan() { + // A:[]→a, B:[a]→goal. Plan: [[A],[B]] + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(2)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA, agentB), state); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).containsExactly("A"); + + // A fails — don't put "a" in state + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(second).isInstanceOf(PlannerAction.RunAgents.class); + // Replan from {}: same plan [[A],[B]], cursor reset → runs A again + assertThat(agentNames((PlannerAction.RunAgents) second)).containsExactly("A"); + } + + @Test + void replan_successAfterReplan_completesNormally() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + SimpleTestAgent agentC = new SimpleTestAgent("C"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of(), "b"), + new AgentMetadata("C", ImmutableList.of("a", "b"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(2)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC), state); + planner.init(context); + + // Step 1: [A,B] + PlannerAction action = planner.firstAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + + // A succeeds, B fails + context.state().put("a", "value_a"); + + // Step 2: replan → [B] + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("B"); + + // B succeeds now + context.state().put("b", "value_b"); + + // Step 3: [C] + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("C"); + + // C succeeds + context.state().put("goal", "result"); + + // Step 4: Done + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void replan_goalAlreadySatisfied_returnsDone() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(1)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, new SimpleTestAgent("B")), state); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + + // A fails, but goal is satisfied by external source + context.state().put("goal", "external_result"); + + // Replan from {"goal"}: search returns empty → Done + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(second).isInstanceOf(PlannerAction.Done.class); + } + + // ── B. MaxAttempts and counter behavior ───────────────────────────────── + + @Test + void replan_maxAttemptsExhausted_returnsDoneWithResult() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(2)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, new SimpleTestAgent("B")), state); + planner.init(context); + + // Run A + planner.firstAction(context).blockingGet(); + // A fails → replan 1 + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + + // A fails again → replan 2 + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + + // A fails again → count=2 >= max=2 → exhausted + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.DoneWithResult.class); + assertThat(((PlannerAction.DoneWithResult) action).result()).contains("max replan attempts"); + assertThat(((PlannerAction.DoneWithResult) action).result()).contains("exhausted"); + } + + @Test + void replan_counterResetsAfterSuccessfulGroup() { + // A:[]→a, B:[a]→b, C:[b]→goal + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + SimpleTestAgent agentC = new SimpleTestAgent("C"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "b"), + new AgentMetadata("C", ImmutableList.of("b"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(2)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC), state); + planner.init(context); + + // Step 1: [A] + planner.firstAction(context).blockingGet(); + + // A fails → replan (count=1) + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("A"); + + // A succeeds → count resets to 0 + context.state().put("a", "value"); + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("B"); + + // B fails → replan (count=1 NOT 2, because counter was reset) + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + // Should still be allowed since count was reset + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("B"); + } + + @Test + void replan_maxAttemptsOne_singleRetry() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + + List metadata = List.of(new AgentMetadata("A", ImmutableList.of(), "a")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner("a", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(1)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA), state); + planner.init(context); + + // Run A + planner.firstAction(context).blockingGet(); + + // A fails → replan (count=1, allowed since count < max before increment) + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + + // A fails again → count=1 >= max=1 → exhausted + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.DoneWithResult.class); + } + + @Test + void replan_maxAttemptsThree_allowsThreeRetries() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + + List metadata = List.of(new AgentMetadata("A", ImmutableList.of(), "a")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner("a", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(3)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA), state); + planner.init(context); + + // Track all RunAgents actions + List actions = new ArrayList<>(); + PlannerAction action = planner.firstAction(context).blockingGet(); + actions.add(action); // original run + + // 3 replans (A fails each time) + for (int i = 0; i < 3; i++) { + action = planner.nextAction(context).blockingGet(); + actions.add(action); + } + + // 4th failure: exhausted + action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.DoneWithResult.class); + + // 4 RunAgents total: 1 original + 3 retries + long runCount = actions.stream().filter(a -> a instanceof PlannerAction.RunAgents).count(); + assertThat(runCount).isEqualTo(4); + } + + // ── C. Policy variant tests ───────────────────────────────────────────── + + @Test + void failStop_missingOutput_returnsDoneWithResult() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.FailStop()); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA, agentB), state); + planner.init(context); + + planner.firstAction(context).blockingGet(); + // A fails + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.DoneWithResult.class); + assertThat(((PlannerAction.DoneWithResult) action).result()).contains("A"); + assertThat(((PlannerAction.DoneWithResult) action).result()).contains("a"); + } + + @Test + void ignore_missingOutput_proceedsToNextGroup() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Ignore()); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA, agentB), state); + planner.init(context); + + planner.firstAction(context).blockingGet(); + // A fails — but Ignore proceeds + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("B"); + } + + @Test + void policyComparison_sameFail_differentOutcomes() { + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "goal")); + + ImmutableList agents = + ImmutableList.of(new SimpleTestAgent("A"), new SimpleTestAgent("B")); + + // FailStop → DoneWithResult + GoalOrientedPlanner failStopPlanner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.FailStop()); + PlanningContext ctx1 = createPlanningContext(agents, new ConcurrentHashMap<>()); + failStopPlanner.init(ctx1); + failStopPlanner.firstAction(ctx1).blockingGet(); + PlannerAction failStopResult = failStopPlanner.nextAction(ctx1).blockingGet(); + assertThat(failStopResult).isInstanceOf(PlannerAction.DoneWithResult.class); + + // Ignore → RunAgents(B) + GoalOrientedPlanner ignorePlanner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Ignore()); + PlanningContext ctx2 = createPlanningContext(agents, new ConcurrentHashMap<>()); + ignorePlanner.init(ctx2); + ignorePlanner.firstAction(ctx2).blockingGet(); + PlannerAction ignoreResult = ignorePlanner.nextAction(ctx2).blockingGet(); + assertThat(ignoreResult).isInstanceOf(PlannerAction.RunAgents.class); + + // Replan → RunAgents(A) (retry) + GoalOrientedPlanner replanPlanner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(1)); + PlanningContext ctx3 = createPlanningContext(agents, new ConcurrentHashMap<>()); + replanPlanner.init(ctx3); + replanPlanner.firstAction(ctx3).blockingGet(); + PlannerAction replanResult = replanPlanner.nextAction(ctx3).blockingGet(); + assertThat(replanResult).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) replanResult)).containsExactly("A"); + } + + // ── D. Edge cases ────────────────────────────────────────────────────── + + @Test + void replan_parallelGroup_partialSuccess_usesPartialState() { + // A:[]→a, B:[]→b, C:[]→c, D:[a,b,c]→goal + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + SimpleTestAgent agentC = new SimpleTestAgent("C"); + SimpleTestAgent agentD = new SimpleTestAgent("D"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of(), "b"), + new AgentMetadata("C", ImmutableList.of(), "c"), + new AgentMetadata("D", ImmutableList.of("a", "b", "c"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(2)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC, agentD), state); + planner.init(context); + + // [A,B,C] in parallel + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(((PlannerAction.RunAgents) first).agents()).hasSize(3); + + // A,C succeed; B fails + context.state().put("a", "va"); + context.state().put("c", "vc"); + + // Replan from {"a","c"}: new plan [[B],[D]] + PlannerAction second = planner.nextAction(context).blockingGet(); + assertThat(second).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) second)).containsExactly("B"); + } + + @Test + void replan_noMissingOutputs_noReplanTriggered() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of("a"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(1)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA, agentB), state); + planner.init(context); + + planner.firstAction(context).blockingGet(); + // A succeeds + context.state().put("a", "value"); + + // No replan triggered, proceeds normally + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("B"); + } + + @Test + void replan_firstGroupNoValidation() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + + List metadata = List.of(new AgentMetadata("A", ImmutableList.of(), "a")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner("a", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(1)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = createPlanningContext(ImmutableList.of(agentA), state); + planner.init(context); + + // firstAction should return RunAgents without any validation + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).containsExactly("A"); + } + + // ── E. Cross-strategy replanning ─────────────────────────────────────── + + @Test + void replan_withDfsStrategy() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + SimpleTestAgent agentC = new SimpleTestAgent("C"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of(), "b"), + new AgentMetadata("C", ImmutableList.of("a", "b"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new DfsSearchStrategy(), new ReplanPolicy.Replan(1)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC), state); + planner.init(context); + + planner.firstAction(context).blockingGet(); + context.state().put("a", "va"); // A succeeds, B fails + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + // DFS replan: only B needed + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("B"); + } + + @Test + void replan_withAStarStrategy() { + SimpleTestAgent agentA = new SimpleTestAgent("A"); + SimpleTestAgent agentB = new SimpleTestAgent("B"); + SimpleTestAgent agentC = new SimpleTestAgent("C"); + + List metadata = + List.of( + new AgentMetadata("A", ImmutableList.of(), "a"), + new AgentMetadata("B", ImmutableList.of(), "b"), + new AgentMetadata("C", ImmutableList.of("a", "b"), "goal")); + + GoalOrientedPlanner planner = + new GoalOrientedPlanner( + "goal", metadata, new AStarSearchStrategy(), new ReplanPolicy.Replan(1)); + ConcurrentHashMap state = new ConcurrentHashMap<>(); + PlanningContext context = + createPlanningContext(ImmutableList.of(agentA, agentB, agentC), state); + planner.init(context); + + planner.firstAction(context).blockingGet(); + context.state().put("a", "va"); // A succeeds, B fails + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + // A* replan: only B needed + assertThat(agentNames((PlannerAction.RunAgents) action)).containsExactly("B"); + } + + // ── F. ReplanPolicy validation ───────────────────────────────────────── + + @Test + void replanPolicy_maxAttemptsZero_throwsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> new ReplanPolicy.Replan(0)); + } + + @Test + void replanPolicy_maxAttemptsNegative_throwsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> new ReplanPolicy.Replan(-1)); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private static List agentNames(PlannerAction.RunAgents action) { + return action.agents().stream().map(BaseAgent::name).toList(); + } + + private static PlanningContext createPlanningContext( + ImmutableList agents, ConcurrentHashMap state) { + 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/P2PLlmCouncilTopologyTest.java b/contrib/planners/src/test/java/com/google/adk/planner/p2p/P2PLlmCouncilTopologyTest.java new file mode 100644 index 000000000..c37b17251 --- /dev/null +++ b/contrib/planners/src/test/java/com/google/adk/planner/p2p/P2PLlmCouncilTopologyTest.java @@ -0,0 +1,828 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link P2PPlanner} against the same realistic council-like 9-agent pipeline used by the + * GOAP {@code CouncilTopologyTest}. Validates P2P-specific behaviors: reactive wave activation, + * iterative refinement via value-change detection, exit conditions, and termination semantics. + * + *

Council topology: + * + *

+ *   initial_response → peer_ranking ──────────→ final_synthesis ─┐
+ *                   → agreement_analysis ──→ aggregate_agreements │→ council_summary
+ *                   → disagreement_analysis → aggregate_disagreements │
+ *                      peer_ranking → aggregate_rankings ─────────┘
+ * 
+ */ +class P2PLlmCouncilTopologyTest { + + // ── Council-like metadata (identical to CouncilTopologyTest) ────────── + + static final List COUNCIL_METADATA = + List.of( + new AgentMetadata("initial_response", ImmutableList.of(), "individual_responses"), + new AgentMetadata( + "peer_ranking", ImmutableList.of("individual_responses"), "peer_rankings"), + new AgentMetadata( + "agreement_analysis", ImmutableList.of("individual_responses"), "agreement_analyses"), + new AgentMetadata( + "disagreement_analysis", + ImmutableList.of("individual_responses"), + "disagreement_analyses"), + new AgentMetadata( + "final_synthesis", + ImmutableList.of("individual_responses", "peer_rankings"), + "final_synthesis"), + new AgentMetadata( + "aggregate_rankings", ImmutableList.of("peer_rankings"), "aggregate_rankings"), + new AgentMetadata( + "aggregate_agreements", + ImmutableList.of("agreement_analyses"), + "aggregate_agreements"), + new AgentMetadata( + "aggregate_disagreements", + ImmutableList.of("disagreement_analyses"), + "aggregate_disagreements"), + new AgentMetadata( + "council_summary", + ImmutableList.of( + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements"), + "council_summary")); + + static final ImmutableList ALL_AGENT_NAMES = + ImmutableList.of( + "initial_response", + "peer_ranking", + "agreement_analysis", + "disagreement_analysis", + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements", + "council_summary"); + + // ── Test infrastructure ──────────────────────────────────────────────── + + 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(); + } + } + + private static ImmutableList councilAgents() { + return ALL_AGENT_NAMES.stream() + .map(SimpleTestAgent::new) + .collect(ImmutableList.toImmutableList()); + } + + 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); + } + + private static List agentNames(PlannerAction.RunAgents action) { + return action.agents().stream().map(BaseAgent::name).toList(); + } + + private static String outputKeyFor(String agentName) { + return COUNCIL_METADATA.stream() + .filter(m -> m.agentName().equals(agentName)) + .findFirst() + .orElseThrow() + .outputKey(); + } + + private static void simulateSuccess(PlanningContext context, List agentNames) { + for (String name : agentNames) { + context.state().put(outputKeyFor(name), "done_by_" + name); + } + } + + /** + * Walks firstAction/nextAction until Done, simulating success at each wave. Unlike GOAP's + * collectAllGroups, P2P requires outputs to appear in state to trigger downstream activation. + */ + private static List> collectAllWaves(P2PPlanner planner, PlanningContext context) { + List> waves = new ArrayList<>(); + PlannerAction action = planner.firstAction(context).blockingGet(); + while (action instanceof PlannerAction.RunAgents run) { + List names = agentNames(run); + waves.add(names); + simulateSuccess(context, names); + action = planner.nextAction(context).blockingGet(); + } + return waves; + } + + // ── Part 1: ReactiveWaveActivation ──────────────────────────────────── + + @Nested + class ReactiveWaveActivation { + + @Test + void fullCouncilProducesFourWaves() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> waves = collectAllWaves(planner, context); + + assertThat(waves).hasSize(4); + assertThat(waves.get(0)).hasSize(1); + assertThat(waves.get(1)).hasSize(3); + assertThat(waves.get(2)).hasSize(4); + assertThat(waves.get(3)).hasSize(1); + } + + @Test + void wave1_onlyInitialResponseActivates() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).containsExactly("initial_response"); + } + + @Test + void wave2_threeAgentsActivateInParallel() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + PlannerAction second = planner.nextAction(context).blockingGet(); + + assertThat(second).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) second)) + .containsExactly("peer_ranking", "agreement_analysis", "disagreement_analysis"); + } + + @Test + void wave3_fourAgentsActivateInParallel() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1 + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + // Wave 2 + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + PlannerAction third = planner.nextAction(context).blockingGet(); + + assertThat(third).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) third)) + .containsExactly( + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements"); + } + + @Test + void wave4_councilSummaryActivatesLast() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Waves 1-3 + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + planner.nextAction(context).blockingGet(); + context.state().put("final_synthesis", "done"); + context.state().put("aggregate_rankings", "done"); + context.state().put("aggregate_agreements", "done"); + context.state().put("aggregate_disagreements", "done"); + + PlannerAction fourth = planner.nextAction(context).blockingGet(); + + assertThat(fourth).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) fourth)).containsExactly("council_summary"); + } + + @Test + void completesWithDoneAfterAllWaves() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> waves = collectAllWaves(planner, context); + assertThat(waves).hasSize(4); + + // collectAllWaves already consumed the final Done; verify by calling nextAction again + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void preExistingState_activatesAllSatisfiedAgents() { + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("individual_responses", "pre-existing"); + + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), state); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + + // P2P activates ALL agents whose inputs are satisfied — unlike GOAP which skips agents + // whose output already exists. initial_response (no inputs) + 3 wave-2 agents all fire. + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + List names = agentNames((PlannerAction.RunAgents) first); + assertThat(names).hasSize(4); + assertThat(names) + .containsExactly( + "initial_response", "peer_ranking", "agreement_analysis", "disagreement_analysis"); + } + + @Test + void preExistingState_multipleKeys_compressesWaves() { + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("individual_responses", "pre-existing"); + state.put("peer_rankings", "pre-existing"); + + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), state); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + List names = agentNames((PlannerAction.RunAgents) first); + // initial_response (no inputs) + peer_ranking, agreement_analysis, disagreement_analysis + // (need individual_responses) + final_synthesis, aggregate_rankings (need peer_rankings) + assertThat(names).hasSize(6); + assertThat(names) + .containsExactly( + "initial_response", + "peer_ranking", + "agreement_analysis", + "disagreement_analysis", + "final_synthesis", + "aggregate_rankings"); + } + + @Test + void p2pWaveGroupingMatchesGoapGrouping() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> waves = collectAllWaves(planner, context); + + // GOAP produces: [initial_response], [peer_ranking, agreement_analysis, + // disagreement_analysis], [final_synthesis, aggregate_rankings, aggregate_agreements, + // aggregate_disagreements], [council_summary] + assertThat(waves).hasSize(4); + assertThat(waves.get(0)).containsExactly("initial_response"); + assertThat(waves.get(1)) + .containsExactly("peer_ranking", "agreement_analysis", "disagreement_analysis"); + assertThat(waves.get(2)) + .containsExactly( + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements"); + assertThat(waves.get(3)).containsExactly("council_summary"); + } + + @Test + void agentsWithNoInputsAlwaysActivateFirst() { + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("unrelated_key", "irrelevant"); + state.put("another_key", "also_irrelevant"); + + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), state); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).contains("initial_response"); + } + } + + // ── Part 2: IterativeRefinement ─────────────────────────────────────── + + @Nested + class IterativeRefinement { + + @Test + void outputChangeTriggersDownstreamReactivation() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 30); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Run full pipeline + collectAllWaves(planner, context); + + // Change individual_responses to a new value + context.state().put("individual_responses", "revised_responses"); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + List reactivated = agentNames((PlannerAction.RunAgents) action); + // final_synthesis also re-activates because individual_responses is one of its inputs + // and peer_rankings (its other input) is already in state + assertThat(reactivated) + .containsExactly( + "peer_ranking", "agreement_analysis", "disagreement_analysis", "final_synthesis"); + } + + @Test + void unchangedOutputDoesNotTriggerReactivation() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 30); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Run full pipeline — collectAllWaves puts "done_by_" for each output + collectAllWaves(planner, context); + + // Put individual_responses back with the SAME value + // (collectAllWaves used "done_by_initial_response") + context.state().put("individual_responses", "done_by_initial_response"); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void cascadingRefinementThroughMultipleWaves() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 30); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Run full pipeline + collectAllWaves(planner, context); + + // Change top-level output + context.state().put("individual_responses", "revised_v2"); + + // Wave 5: peer_ranking, agreement_analysis, disagreement_analysis + final_synthesis + // (final_synthesis also has individual_responses as input, and peer_rankings is in state) + PlannerAction wave5 = planner.nextAction(context).blockingGet(); + assertThat(wave5).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) wave5)).hasSize(4); + + // Simulate wave 5 producing new values + context.state().put("peer_rankings", "revised_rankings"); + context.state().put("agreement_analyses", "revised_agreements"); + context.state().put("disagreement_analyses", "revised_disagreements"); + context.state().put("final_synthesis", "revised_synthesis_wave5"); + + // Wave 6: broad re-activation because P2P broadcasts all changes: + // - peer_rankings changed → final_synthesis + aggregate_rankings + // - agreement_analyses changed → aggregate_agreements + // - disagreement_analyses changed → aggregate_disagreements + // - final_synthesis changed → council_summary + PlannerAction wave6 = planner.nextAction(context).blockingGet(); + assertThat(wave6).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) wave6)) + .containsExactly( + "final_synthesis", + "aggregate_rankings", + "aggregate_agreements", + "aggregate_disagreements", + "council_summary"); + } + + @Test + void refinementOnlyAffectsAgentsWithChangedInputs() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 30); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + collectAllWaves(planner, context); + + // Only change peer_rankings + context.state().put("peer_rankings", "new_rankings"); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + List reactivated = agentNames((PlannerAction.RunAgents) action); + // Only agents with peer_rankings as input: final_synthesis and aggregate_rankings + assertThat(reactivated).containsExactly("final_synthesis", "aggregate_rankings"); + assertThat(reactivated).doesNotContain("aggregate_agreements"); + assertThat(reactivated).doesNotContain("aggregate_disagreements"); + } + + @Test + void multipleOutputChangesInSingleWave() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 30); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + collectAllWaves(planner, context); + + // Change two outputs simultaneously + context.state().put("peer_rankings", "new_rankings"); + context.state().put("agreement_analyses", "new_agreements"); + + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + List reactivated = agentNames((PlannerAction.RunAgents) action); + // Union of agents affected by either change + assertThat(reactivated) + .containsAtLeast("final_synthesis", "aggregate_rankings", "aggregate_agreements"); + } + + @Test + void agentDoesNotReactivateFromItsOwnOutput() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1: initial_response activates + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Wave 2: downstream agents activate + PlannerAction wave2 = planner.nextAction(context).blockingGet(); + assertThat(wave2).isInstanceOf(PlannerAction.RunAgents.class); + // initial_response should NOT re-activate (it has no inputs, so onStateChanged is a no-op) + assertThat(agentNames((PlannerAction.RunAgents) wave2)).doesNotContain("initial_response"); + } + + @Test + void refinementWithMaxInvocationsLimit() { + // 9 agents in full pipeline + 4 re-activations = 13 + // (final_synthesis also re-activates because individual_responses is one of its inputs) + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 13); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Full pipeline: 9 invocations + collectAllWaves(planner, context); + + // Trigger refinement + context.state().put("individual_responses", "revised"); + + // 4 more agents activate (total 13 = maxInvocations) + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) action)).hasSize(4); + + // Simulate their success + context.state().put("peer_rankings", "revised_rankings"); + context.state().put("agreement_analyses", "revised_agreements"); + context.state().put("disagreement_analyses", "revised_disagreements"); + context.state().put("final_synthesis", "revised_synthesis"); + + // maxInvocations reached — no more agents can activate + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void refinementWithExitCondition() { + P2PPlanner planner = + new P2PPlanner( + COUNCIL_METADATA, 30, (state, count) -> "final".equals(state.get("council_summary"))); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Full pipeline produces council_summary = "done_by_council_summary" + collectAllWaves(planner, context); + + // Trigger refinement + context.state().put("individual_responses", "revised"); + + // Exit condition not yet met (council_summary != "final") + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + + // Now set council_summary to "final" to trigger exit + context.state().put("council_summary", "final"); + + // Finish current wave + context.state().put("peer_rankings", "revised"); + context.state().put("agreement_analyses", "revised"); + context.state().put("disagreement_analyses", "revised"); + + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void noRefinementWhenAgentProducesNothing() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1: initial_response activates + planner.firstAction(context).blockingGet(); + // Agent "fails" — does NOT put individual_responses in state + + // No output value changed, so no downstream agents get shouldExecute=true + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + } + + // ── Part 3: TerminationBehavior ─────────────────────────────────────── + + @Nested + class TerminationBehavior { + + @Test + void naturalTermination_noMoreActivatableAgents() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + collectAllWaves(planner, context); + + // No value changes → no agents can activate + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void maxInvocations_stopsBeforeWave2() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 1); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1: initial_response activates (count=1) + PlannerAction first = planner.firstAction(context).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + context.state().put("individual_responses", "done"); + + // maxInvocations=1 reached → Done, even though wave-2 agents could activate + PlannerAction action = planner.nextAction(context).blockingGet(); + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void maxInvocations_stopsAfterPartialPipeline() { + // Waves 1 (1 agent) + wave 2 (3 agents) = 4 invocations + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 4); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1 + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Wave 2: 3 agents (total count=4) + PlannerAction wave2 = planner.nextAction(context).blockingGet(); + assertThat(wave2).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) wave2)).hasSize(3); + + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + // Count=4 >= maxInvocations=4 → Done + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void maxInvocations_exactlyCoversFullPipeline() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 9); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + List> waves = collectAllWaves(planner, context); + + // All 9 agents execute across 4 waves + assertThat(waves).hasSize(4); + int totalAgents = waves.stream().mapToInt(List::size).sum(); + assertThat(totalAgents).isEqualTo(9); + } + + @Test + void exitCondition_checksStateAndCount() { + P2PPlanner planner = + new P2PPlanner( + COUNCIL_METADATA, + 20, + (state, count) -> count >= 4 && state.containsKey("peer_rankings")); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1: count=1 + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Wave 2: count=4, peer_rankings will be produced + PlannerAction wave2 = planner.nextAction(context).blockingGet(); + assertThat(wave2).isInstanceOf(PlannerAction.RunAgents.class); + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + // Exit condition: count>=4 AND peer_rankings present → Done + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void exitCondition_checkedBeforeActivation() { + P2PPlanner planner = + new P2PPlanner( + COUNCIL_METADATA, 20, (state, count) -> state.containsKey("individual_responses")); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1 + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Exit condition fires before wave-2 agents are scanned + PlannerAction done = planner.nextAction(context).blockingGet(); + assertThat(done).isInstanceOf(PlannerAction.Done.class); + } + } + + // ── Part 4: EdgeCasesAndBoundaries ──────────────────────────────────── + + @Nested + class EdgeCasesAndBoundaries { + + @Test + void emptyState_noAgentsCanActivateExceptNoInputAgent() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).containsExactly("initial_response"); + } + + @Test + void allOutputsPrePopulated_allAgentsActivateInOneWave() { + ConcurrentHashMap state = new ConcurrentHashMap<>(); + state.put("individual_responses", "pre"); + state.put("peer_rankings", "pre"); + state.put("agreement_analyses", "pre"); + state.put("disagreement_analyses", "pre"); + state.put("final_synthesis", "pre"); + state.put("aggregate_rankings", "pre"); + state.put("aggregate_agreements", "pre"); + state.put("aggregate_disagreements", "pre"); + + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), state); + planner.init(context); + + PlannerAction first = planner.firstAction(context).blockingGet(); + + // All 9 agents fire simultaneously — P2P doesn't enforce topological order + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).hasSize(9); + } + + @Test + void partialWave2Failure_onlySuccessfulOutputsTriggerWave3() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Wave 1 succeeds + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + + // Wave 2 runs + planner.nextAction(context).blockingGet(); + // Only peer_ranking succeeds + context.state().put("peer_rankings", "done"); + // agreement_analysis and disagreement_analysis fail (no output) + + PlannerAction action = planner.nextAction(context).blockingGet(); + + assertThat(action).isInstanceOf(PlannerAction.RunAgents.class); + List activated = agentNames((PlannerAction.RunAgents) action); + // Only agents whose inputs are fully satisfied + assertThat(activated).containsAtLeast("final_synthesis", "aggregate_rankings"); + assertThat(activated).doesNotContain("aggregate_agreements"); + assertThat(activated).doesNotContain("aggregate_disagreements"); + assertThat(activated).doesNotContain("council_summary"); + } + + @Test + void councilSummaryBlockedUntilAllFourInputsPresent() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context); + + // Run waves 1-2 + planner.firstAction(context).blockingGet(); + context.state().put("individual_responses", "done"); + planner.nextAction(context).blockingGet(); + context.state().put("peer_rankings", "done"); + context.state().put("agreement_analyses", "done"); + context.state().put("disagreement_analyses", "done"); + + // Wave 3 runs + planner.nextAction(context).blockingGet(); + // Produce only 3 of 4 outputs — omit aggregate_disagreements + context.state().put("final_synthesis", "done"); + context.state().put("aggregate_rankings", "done"); + context.state().put("aggregate_agreements", "done"); + + PlannerAction action = planner.nextAction(context).blockingGet(); + + // council_summary needs all 4 inputs but only 3 are present → stays blocked + assertThat(action).isInstanceOf(PlannerAction.Done.class); + } + + @Test + void reinitResetsAllActivatorState() { + P2PPlanner planner = new P2PPlanner(COUNCIL_METADATA, 20); + PlanningContext context1 = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context1); + + // Run partial pipeline (waves 1-2) + planner.firstAction(context1).blockingGet(); + context1.state().put("individual_responses", "done"); + planner.nextAction(context1).blockingGet(); + + // Re-init with fresh state + PlanningContext context2 = createPlanningContext(councilAgents(), new ConcurrentHashMap<>()); + planner.init(context2); + + // Should start fresh: initial_response activates + PlannerAction first = planner.firstAction(context2).blockingGet(); + assertThat(first).isInstanceOf(PlannerAction.RunAgents.class); + assertThat(agentNames((PlannerAction.RunAgents) first)).containsExactly("initial_response"); + } + } +} diff --git a/pom.xml b/pom.xml index b80734b2c..d9347eb44 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ contrib/langchain4j contrib/spring-ai contrib/samples + contrib/planners contrib/firestore-session-service tutorials/city-time-weather tutorials/live-audio-single-agent