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:
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
user-1 events: 5, keys: [findings]
|
||||
user-2 events: 0, keys: []
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user