From d5725f66ba64f93c39560ff1b4c9a571f9dcdd10 Mon Sep 17 00:00:00 2001 From: asmhatre Date: Wed, 14 Oct 2026 06:47:50 +0000 Subject: [PATCH] Add adk module: two LlmAgents in a SequentialAgent with a tool and shared session state on Google ADK for Java 1.11.0 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RjfyTgmERTWLqzafu34Cth --- README.md | 2 + adk/README.md | 22 +++++ adk/output/01-two-agents-in-order.txt | 6 ++ .../02-state-injected-into-instruction.txt | 5 + adk/output/03-sessions-per-user.txt | 2 + adk/pom.xml | 33 +++++++ .../main/java/com/ankurm/adk/OrderTools.java | 29 ++++++ .../main/java/com/ankurm/adk/ScriptedLlm.java | 72 ++++++++++++++ .../java/com/ankurm/adk/SupportPipeline.java | 46 +++++++++ .../com/ankurm/adk/SupportPipelineTest.java | 99 +++++++++++++++++++ .../test/java/com/ankurm/adk/Transcript.java | 23 +++++ pom.xml | 2 + 12 files changed, 341 insertions(+) create mode 100644 adk/README.md create mode 100644 adk/output/01-two-agents-in-order.txt create mode 100644 adk/output/02-state-injected-into-instruction.txt create mode 100644 adk/output/03-sessions-per-user.txt create mode 100644 adk/pom.xml create mode 100644 adk/src/main/java/com/ankurm/adk/OrderTools.java create mode 100644 adk/src/main/java/com/ankurm/adk/ScriptedLlm.java create mode 100644 adk/src/main/java/com/ankurm/adk/SupportPipeline.java create mode 100644 adk/src/test/java/com/ankurm/adk/SupportPipelineTest.java create mode 100644 adk/src/test/java/com/ankurm/adk/Transcript.java diff --git a/README.md b/README.md index 0b055e6..e2bfba5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ facts, so the build fails when a claim stops being true. | `onnx-djl` | Running ONNX Models in Java with ONNX Runtime and DJL | a DistilBERT sentiment model: DJL tokenizer, ONNX Runtime directly and through DJL's engine, batching, and a Spring Boot endpoint with latency numbers | | `jlama` | Local LLM Inference in Pure Java with Jlama | load and run a 4-bit TinyLlama inside the JVM on the Vector API, tokens per second, streaming, and the same measurement against Ollama | | `langgraph4j` | LangGraph4j: Stateful Agent Graphs in Java | a loan-approval graph with conditional edges, checkpoints, a human-in-the-loop pause and resume, and the `releaseThread` default that erases a finished run | +| `adk` | Google ADK for Java: Your First Multi-Agent App | two `LlmAgent`s in a `SequentialAgent`, a tool call, session state handed from one agent to the next, with a scripted stand-in model | ## Versions (verified on Maven Central, 2026-10-11) @@ -25,6 +26,7 @@ facts, so the build fails when a claim stops being true. | Spring Boot | 4.1.1 | `onnx-djl` endpoint only | | Model | `distilbert/distilbert-base-uncased-finetuned-sst-2-english` | `onnx/model.onnx` (268 MB), `onnx/tokenizer.json`, `config.json` in `onnx-djl/models/sst2/` (git-ignored) | | LangGraph4j `org.bsc.langgraph4j:langgraph4j-core` | 1.9.5 | verified on Maven Central 2026-10-14; `langgraph4j` module only | +| Google ADK `com.google.adk:google-adk` | 1.11.0 | verified on Maven Central 2026-10-14; `adk` module only; no Gemini key needed | | JDK | 25 LTS | | | JUnit | 6.1.3 | | diff --git a/adk/README.md b/adk/README.md new file mode 100644 index 0000000..3a4bd76 --- /dev/null +++ b/adk/README.md @@ -0,0 +1,22 @@ +# adk + +Companion code for *Google Agent Development Kit (ADK) for Java: Your First Multi-Agent App* on +[ankurm.com](https://ankurm.com). Two `LlmAgent`s in a `SequentialAgent`: a researcher that calls a tool and +saves its answer to session state, and a writer whose instruction reads that state. + +There is no Gemini key here. `ScriptedLlm` is a small `BaseLlm` that behaves like a tool-calling model, so the +agents, the tool call, the session and the state hand-off all run for real while the model is the one +scripted part. + +```bash +mvn test -pl adk # rewrites output/*.txt +``` + +| File | Role | +|---|---| +| `OrderTools` | The tool: a public static method with `@Schema` parameter annotations | +| `ScriptedLlm` | The stand-in model; records every `LlmRequest` it receives | +| `SupportPipeline` | The two agents and the `SequentialAgent` that runs them | +| `SupportPipelineTest` | Three tests; each writes `output/NN-*.txt`. Map entries inside tool results print in a different order from run to run, so compare content, not line order | + +Verified against `com.google.adk:google-adk` **1.11.0**, JDK 25, JUnit 6.1.3. diff --git a/adk/output/01-two-agents-in-order.txt b/adk/output/01-two-agents-in-order.txt new file mode 100644 index 0000000..7667a0a --- /dev/null +++ b/adk/output/01-two-agents-in-order.txt @@ -0,0 +1,6 @@ +researcher call lookupOrder{orderId=A-1001} +researcher result lookupOrder{orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok} +researcher text: Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok} +writer text: Hello, here is your update. Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok} +session state keys: [findings] +state[findings] = Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok} diff --git a/adk/output/02-state-injected-into-instruction.txt b/adk/output/02-state-injected-into-instruction.txt new file mode 100644 index 0000000..14ed758 --- /dev/null +++ b/adk/output/02-state-injected-into-instruction.txt @@ -0,0 +1,5 @@ +model calls made: 3 +call 1: tools offered = [lookupOrder] +call 2: tools offered = [lookupOrder] +call 3: tools offered = [] +writer system instruction contains the findings: true diff --git a/adk/output/03-sessions-per-user.txt b/adk/output/03-sessions-per-user.txt new file mode 100644 index 0000000..1a9fb80 --- /dev/null +++ b/adk/output/03-sessions-per-user.txt @@ -0,0 +1,2 @@ +user-1 events: 5, keys: [findings] +user-2 events: 0, keys: [] diff --git a/adk/pom.xml b/adk/pom.xml new file mode 100644 index 0000000..2703fce --- /dev/null +++ b/adk/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + + com.ankurm.agents + java-ai-agents + 1.0.0 + + + adk + Google ADK for Java: two-agent support pipeline + + + + com.google.adk + google-adk + ${adk.version} + + + org.slf4j + slf4j-nop + ${slf4j.version} + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/adk/src/main/java/com/ankurm/adk/OrderTools.java b/adk/src/main/java/com/ankurm/adk/OrderTools.java new file mode 100644 index 0000000..2012422 --- /dev/null +++ b/adk/src/main/java/com/ankurm/adk/OrderTools.java @@ -0,0 +1,29 @@ +package com.ankurm.adk; + +import com.google.adk.tools.Annotations.Schema; + +import java.util.Map; + +/** + * The tool the "researcher" agent can call. ADK turns a public static method into a function + * declaration for the model: the method name becomes the tool name and each {@code @Schema} + * annotation becomes a parameter description the model sees. + */ +public final class OrderTools { + + private OrderTools() {} + + private static final Map ORDERS = Map.of( + "A-1001", "shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15", + "A-1002", "payment pending, no shipment yet"); + + /** Looks up an order. The model must pass the order id as a string. */ + public static Map lookupOrder( + @Schema(name = "orderId", description = "The order id such as A-1001") String orderId) { + String status = ORDERS.get(orderId); + if (status == null) { + return Map.of("status", "error", "message", "no order " + orderId); + } + return Map.of("status", "ok", "orderId", orderId, "detail", status); + } +} diff --git a/adk/src/main/java/com/ankurm/adk/ScriptedLlm.java b/adk/src/main/java/com/ankurm/adk/ScriptedLlm.java new file mode 100644 index 0000000..1f2a9dc --- /dev/null +++ b/adk/src/main/java/com/ankurm/adk/ScriptedLlm.java @@ -0,0 +1,72 @@ +package com.ankurm.adk; + +import com.google.adk.models.BaseLlm; +import com.google.adk.models.BaseLlmConnection; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Flowable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * A stand-in for Gemini so the whole pipeline runs with no API key and no network. + * + *

It does what a tool-calling model does, in the simplest possible way: + *

    + *
  1. if the conversation already holds a function response, it answers in plain text;
  2. + *
  3. otherwise, if the request carries a tool, it asks to call that tool;
  4. + *
  5. otherwise it writes text from its system instruction.
  6. + *
+ * Every request it receives is recorded so a test can inspect what ADK actually sent. + */ +public final class ScriptedLlm extends BaseLlm { + + public final List seen = new ArrayList<>(); + + public ScriptedLlm() { + super("scripted-model"); + } + + @Override + public Flowable generateContent(LlmRequest request, boolean stream) { + seen.add(request); + Content last = request.contents().isEmpty() ? null : request.contents().get(request.contents().size() - 1); + boolean hasToolResult = last != null && last.parts().isPresent() + && last.parts().get().stream().anyMatch(p -> p.functionResponse().isPresent()); + + Content reply; + if (hasToolResult) { + String result = last.parts().get().stream() + .map(p -> p.functionResponse().get().response().orElse(Map.of()).toString()) + .findFirst().orElse(""); + reply = text("Findings: " + result); + } else if (!request.tools().isEmpty()) { + String tool = request.tools().keySet().iterator().next(); + reply = Content.builder().role("model") + .parts(Part.fromFunctionCall(tool, Map.of("orderId", "A-1001"))).build(); + } else { + String instruction = request.getFirstSystemInstruction().orElse(""); + int at = instruction.indexOf("Findings:"); + String facts = "(no findings were passed in)"; + if (at >= 0) { + int end = instruction.indexOf('\n', at); // ADK appends an identity paragraph after ours + facts = end > 0 ? instruction.substring(at, end) : instruction.substring(at); + } + reply = text("Hello, here is your update. " + facts); + } + return Flowable.just(LlmResponse.builder().content(reply).build()); + } + + @Override + public BaseLlmConnection connect(LlmRequest request) { + throw new UnsupportedOperationException("live connections are not needed here"); + } + + private static Content text(String s) { + return Content.builder().role("model").parts(Part.fromText(s)).build(); + } +} diff --git a/adk/src/main/java/com/ankurm/adk/SupportPipeline.java b/adk/src/main/java/com/ankurm/adk/SupportPipeline.java new file mode 100644 index 0000000..ddac6cf --- /dev/null +++ b/adk/src/main/java/com/ankurm/adk/SupportPipeline.java @@ -0,0 +1,46 @@ +package com.ankurm.adk; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.SequentialAgent; +import com.google.adk.models.BaseLlm; +import com.google.adk.tools.FunctionTool; + +/** + * Two agents in a fixed order: + *
+ *   researcher  (has the lookupOrder tool, saves its answer under state key "findings")
+ *       |
+ *   writer      (no tools, its instruction reads {findings} from session state)
+ * 
+ * A {@link SequentialAgent} runs its sub-agents one after the other in the same session, so the + * shared session state is how the first agent hands work to the second. + */ +public final class SupportPipeline { + + private SupportPipeline() {} + + public static BaseAgent build(BaseLlm model) { + LlmAgent researcher = LlmAgent.builder() + .name("researcher") + .description("Looks up the order the customer asks about.") + .model(model) + .instruction("Find the order the customer mentions by calling the lookupOrder tool.") + .tools(FunctionTool.create(OrderTools.class, "lookupOrder")) + .outputKey("findings") + .build(); + + LlmAgent writer = LlmAgent.builder() + .name("writer") + .description("Writes the customer reply from the research.") + .model(model) + .instruction("Write a short friendly reply to the customer using only this: {findings}") + .build(); + + return SequentialAgent.builder() + .name("support_pipeline") + .description("Research the order, then write the reply.") + .subAgents(researcher, writer) + .build(); + } +} diff --git a/adk/src/test/java/com/ankurm/adk/SupportPipelineTest.java b/adk/src/test/java/com/ankurm/adk/SupportPipelineTest.java new file mode 100644 index 0000000..0da08dc --- /dev/null +++ b/adk/src/test/java/com/ankurm/adk/SupportPipelineTest.java @@ -0,0 +1,99 @@ +package com.ankurm.adk; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.events.Event; +import com.google.adk.runner.InMemoryRunner; +import com.google.adk.sessions.Session; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SupportPipelineTest { + + private static final String APP = "support-app"; + + private static String describe(Event e) { + StringBuilder sb = new StringBuilder(); + e.content().flatMap(Content::parts).ifPresent(parts -> { + for (Part p : parts) { + p.text().ifPresent(t -> sb.append("text: ").append(t)); + p.functionCall().ifPresent(f -> sb.append("call ").append(f.name().orElse("?")) + .append(f.args().orElse(java.util.Map.of()))); + p.functionResponse().ifPresent(f -> sb.append("result ").append(f.name().orElse("?")) + .append(f.response().orElse(java.util.Map.of()))); + } + }); + return sb.toString(); + } + + @Test + void twoAgentsRunInOrderAndShareSessionState() throws Exception { + var t = new Transcript("01-two-agents-in-order.txt"); + var llm = new ScriptedLlm(); + BaseAgent root = SupportPipeline.build(llm); + var runner = new InMemoryRunner(root, APP); + Session session = runner.sessionService().createSession(APP, "user-1").blockingGet(); + + List events = runner.runAsync("user-1", session.id(), + Content.fromParts(Part.fromText("Where is my order A-1001?"))).toList().blockingGet(); + + for (Event e : events) { + t.line(String.format("%-11s %s", e.author(), describe(e))); + } + + Session after = runner.sessionService().getSession(APP, "user-1", session.id(), java.util.Optional.empty()).blockingGet(); + t.line("session state keys: " + after.state().keySet()); + t.line("state[findings] = " + after.state().get("findings")); + t.write(); + + assertEquals("researcher", events.get(0).author()); + assertEquals("writer", events.get(events.size() - 1).author()); + assertTrue(after.state().containsKey("findings")); + assertTrue(describe(events.get(events.size() - 1)).contains("BlueDart")); + } + + @Test + void theWriterModelReceivesTheResearcherOutputInItsInstruction() throws Exception { + var t = new Transcript("02-state-injected-into-instruction.txt"); + var llm = new ScriptedLlm(); + var runner = new InMemoryRunner(SupportPipeline.build(llm), APP); + Session session = runner.sessionService().createSession(APP, "user-2").blockingGet(); + runner.runAsync("user-2", session.id(), Content.fromParts(Part.fromText("Status of A-1001?"))) + .toList().blockingGet(); + + t.line("model calls made: " + llm.seen.size()); + for (int i = 0; i < llm.seen.size(); i++) { + var r = llm.seen.get(i); + t.line("call " + (i + 1) + ": tools offered = " + r.tools().keySet()); + } + String writerInstruction = llm.seen.get(llm.seen.size() - 1).getFirstSystemInstruction().orElse(""); + t.line("writer system instruction contains the findings: " + writerInstruction.contains("BlueDart")); + t.write(); + + assertEquals(3, llm.seen.size()); // researcher x2 (call, then answer), writer x1 + assertTrue(writerInstruction.contains("BlueDart")); + assertFalse(writerInstruction.contains("{findings}")); + } + + @Test + void sessionsAreSeparatePerUser() throws Exception { + var t = new Transcript("03-sessions-per-user.txt"); + var runner = new InMemoryRunner(SupportPipeline.build(new ScriptedLlm()), APP); + Session s1 = runner.sessionService().createSession(APP, "user-1").blockingGet(); + Session s2 = runner.sessionService().createSession(APP, "user-2").blockingGet(); + runner.runAsync("user-1", s1.id(), Content.fromParts(Part.fromText("order A-1001"))).toList().blockingGet(); + + var a = runner.sessionService().getSession(APP, "user-1", s1.id(), java.util.Optional.empty()).blockingGet(); + var b = runner.sessionService().getSession(APP, "user-2", s2.id(), java.util.Optional.empty()).blockingGet(); + t.line("user-1 events: " + a.events().size() + ", keys: " + a.state().keySet()); + t.line("user-2 events: " + b.events().size() + ", keys: " + b.state().keySet()); + t.write(); + + assertTrue(a.events().size() > 0); + assertEquals(0, b.events().size()); + } +} diff --git a/adk/src/test/java/com/ankurm/adk/Transcript.java b/adk/src/test/java/com/ankurm/adk/Transcript.java new file mode 100644 index 0000000..465f03a --- /dev/null +++ b/adk/src/test/java/com/ankurm/adk/Transcript.java @@ -0,0 +1,23 @@ +package com.ankurm.adk; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */ +final class Transcript { + private final List lines = new ArrayList<>(); + private final String file; + + Transcript(String file) { this.file = file; } + + void line(String s) { lines.add(s); } + + void write() throws IOException { + Path dir = Path.of("output"); + Files.createDirectories(dir); + Files.write(dir.resolve(file), lines); + } +} diff --git a/pom.xml b/pom.xml index 1e49008..61af776 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ jlama onnx-djl langgraph4j + adk @@ -27,6 +28,7 @@ 0.38.0 1.9.5 + 1.11.0 1.31.0 4.1.1 6.1.3