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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjfyTgmERTWLqzafu34Cth
This commit is contained in:
2026-10-14 06:47:50 +00:00
co-authored by Claude Sonnet 5.5
parent 3b52a434f5
commit d5725f66ba
12 changed files with 341 additions and 0 deletions
+2
View File
@@ -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 | |
+22
View File
@@ -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.
+6
View File
@@ -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}
@@ -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
+2
View File
@@ -0,0 +1,2 @@
user-1 events: 5, keys: [findings]
user-2 events: 0, keys: []
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.agents</groupId>
<artifactId>java-ai-agents</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>adk</artifactId>
<name>Google ADK for Java: two-agent support pipeline</name>
<dependencies>
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk</artifactId>
<version>${adk.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-nop</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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<String, String> 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<String, Object> 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);
}
}
@@ -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.
*
* <p>It does what a tool-calling model does, in the simplest possible way:
* <ol>
* <li>if the conversation already holds a function response, it answers in plain text;</li>
* <li>otherwise, if the request carries a tool, it asks to call that tool;</li>
* <li>otherwise it writes text from its system instruction.</li>
* </ol>
* Every request it receives is recorded so a test can inspect what ADK actually sent.
*/
public final class ScriptedLlm extends BaseLlm {
public final List<LlmRequest> seen = new ArrayList<>();
public ScriptedLlm() {
super("scripted-model");
}
@Override
public Flowable<LlmResponse> 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();
}
}
@@ -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:
* <pre>
* researcher (has the lookupOrder tool, saves its answer under state key "findings")
* |
* writer (no tools, its instruction reads {findings} from session state)
* </pre>
* 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();
}
}
@@ -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<Event> 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());
}
}
@@ -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<String> 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);
}
}
+2
View File
@@ -15,6 +15,7 @@
<module>jlama</module>
<module>onnx-djl</module>
<module>langgraph4j</module>
<module>adk</module>
</modules>
<properties>
@@ -27,6 +28,7 @@
<djl.version>0.38.0</djl.version>
<!-- Verified on Maven Central 2026-10-14 -->
<langgraph4j.version>1.9.5</langgraph4j.version>
<adk.version>1.11.0</adk.version>
<onnxruntime.version>1.31.0</onnxruntime.version>
<spring-boot.version>4.1.1</spring-boot.version>
<junit.version>6.1.3</junit.version>