Compare commits

...
3 Commits
44 changed files with 1536 additions and 0 deletions
+6
View File
@@ -10,6 +10,9 @@ facts, so the build fails when a claim stops being true.
| `a2a` | A2A Protocol in Java: Agents That Talk to Each Other | agent card, tasks, streaming, input-required and agent-to-agent calls with the A2A Java SDK |
| `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 |
| `code-review-bot` | Build an AI Code-Review Bot in Java | a signed-webhook receiver, a diff chunker, a Spring AI reviewer with a cache and a chunk cap, and a guard that drops comments on lines the model invented |
## Versions (verified on Maven Central, 2026-10-11)
@@ -23,6 +26,9 @@ facts, so the build fails when a claim stops being true.
| DJL (`ai.djl`) | 0.38.0 | `api`, `huggingface:tokenizers`, `onnxruntime:onnxruntime-engine` |
| 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 |
| Spring AI `spring-ai-client-chat` | 2.0.1 | `code-review-bot` only; GA, with 2.1.0-M1 a milestone as of 2026-10-14 |
| 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);
}
}
+23
View File
@@ -0,0 +1,23 @@
# code-review-bot
Companion code for *Build an AI Code-Review Bot in Java (Gitea/GitHub Webhook + Spring AI)* on
[ankurm.com](https://ankurm.com). A webhook receiver that checks Gitea's HMAC signature, a diff chunker that numbers
lines of the new file, a Spring AI `ChatClient` that reviews each chunk, and a posting step that keeps only comments
that can be attached to a line. No API key: the tests use `ScriptedChatModel`, a scripted `ChatModel`.
```bash
mvn test -pl code-review-bot # rewrites output/*.txt
```
| File | Role |
|---|---|
| `WebhookController` | Verifies `X-Gitea-Signature` over the raw body, filters events, queues the review and answers `202` |
| `Hmac` | Bare lowercase hex HMAC-SHA256 with a constant-time comparison |
| `DiffChunker` | Unified diff to chunks: file boundaries respected, new-file line numbers, lockfiles and binaries skipped |
| `ReviewService` | The prompt, the content-keyed cache, the chunk cap, the severity filter, the "is this an added line?" guard |
| `GitHost`, `GiteaHost` | The two calls the bot needs; the Gitea endpoints were checked against Gitea 1.27.3's swagger. `GiteaHost` is not exercised against a live server by the tests |
Not covered here: running it against a real model, GitHub's payload (the `X-Hub-Signature-256` header carries a
`sha256=` prefix), and review threading on later pushes.
Verified with Spring Boot 4.1.1, Spring AI `spring-ai-client-chat` **2.0.1**, JDK 25, JUnit 6.1.3.
@@ -0,0 +1,9 @@
chunks: 1, skipped files: 1
file src/Login.java added lines [2, 3, 5]
1| class Login {
2| + String password = "hunter2";
3| + // TODO move to vault
4| void run() {
5| + System.out.println("login");
6| }
7| }
@@ -0,0 +1,5 @@
chunk 1: 199 chars, lines 1..13
chunk 2: 190 chars, lines 14..23
chunk 3: 190 chars, lines 24..33
chunk 4: 152 chars, lines 34..40
chunks: 4
@@ -0,0 +1,3 @@
comment src/Login.java:2 [MAJOR] This looks like a hard-coded credential.
comment src/Login.java:5 [MINOR] Use a logger instead of System.out.
summary: Summary[chunksTotal=1, skippedFiles=1, skippedByBudget=0, cacheHits=0, modelCalls=1, commentsPosted=2, droppedBadLine=0, droppedNits=1, approxInputChars=455]
@@ -0,0 +1,3 @@
posted line 2
posted line 5
dropped because the line is not an added line: 2
@@ -0,0 +1,3 @@
first review: model calls 3, cache hits 0
same diff again: model calls 0, cache hits 3
one file edited: model calls 1, cache hits 2
+4
View File
@@ -0,0 +1,4 @@
chunks in diff: 8
model calls: 3
chunks skipped by the cap: 5
approximate characters sent: 993
+6
View File
@@ -0,0 +1,6 @@
wrong signature -> 401
no signature -> 401
comment event -> 200 ignored: pull_request_comment/synchronized
valid sync event -> 202 queued asmhatre/demo#7
jobs queued: 1, reviews posted before the job runs: 0
reviews posted after the job runs: 1
+44
View File
@@ -0,0 +1,44 @@
<?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>code-review-bot</artifactId>
<name>AI code-review bot: Gitea webhook plus Spring AI</name>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,97 @@
package com.ankurm.reviewbot;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
/**
* Turns a unified diff into review-sized chunks.
*
* <p>A chunk never crosses a file boundary and never splits a diff line. Each line is prefixed with
* its line number in the NEW file so the model can answer with a number the host can attach a
* comment to. Lockfiles and minified or generated files are skipped before anything is sent.
*/
public final class DiffChunker {
public record Chunk(String file, String text, Set<Integer> addedLines) {}
public record Result(List<Chunk> chunks, int skippedFiles) {}
private final int maxChars;
public DiffChunker(int maxChars) {
this.maxChars = maxChars;
}
static boolean ignored(String path) {
return path.endsWith(".lock") || path.endsWith("-lock.json") || path.endsWith(".min.js")
|| path.contains("/generated/") || path.endsWith(".png") || path.endsWith(".jar");
}
public Result chunk(String diff) {
List<Chunk> out = new ArrayList<>();
int skipped = 0;
String file = null;
boolean skipFile = false;
int newLine = 0;
StringBuilder text = new StringBuilder();
Set<Integer> added = new TreeSet<>();
for (String line : diff.split("\n", -1)) {
if (line.startsWith("diff --git ")) {
flush(out, file, text, added);
int b = line.lastIndexOf(" b/");
file = b >= 0 ? line.substring(b + 3) : line;
skipFile = ignored(file);
if (skipFile) {
skipped++;
}
continue;
}
if (file == null || skipFile) {
continue;
}
if (line.startsWith("@@")) {
int plus = line.indexOf('+');
int end = plus + 1;
while (end < line.length() && Character.isDigit(line.charAt(end))) {
end++;
}
newLine = Integer.parseInt(line.substring(plus + 1, end));
continue;
}
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("index ")
|| line.startsWith("new file") || line.startsWith("deleted file")) {
continue;
}
String rendered;
if (line.startsWith("+")) {
rendered = newLine + "| +" + line.substring(1);
added.add(newLine);
newLine++;
} else if (line.startsWith("-")) {
rendered = " | -" + line.substring(1);
} else if (line.startsWith(" ")) {
rendered = newLine + "| " + line.substring(1);
newLine++;
} else {
continue;
}
if (text.length() + rendered.length() + 1 > maxChars && text.length() > 0) {
flush(out, file, text, added);
}
text.append(rendered).append('\n');
}
flush(out, file, text, added);
return new Result(out, skipped);
}
private static void flush(List<Chunk> out, String file, StringBuilder text, Set<Integer> added) {
if (file != null && text.length() > 0 && !added.isEmpty()) {
out.add(new Chunk(file, text.toString(), new TreeSet<>(added)));
}
text.setLength(0);
added.clear();
}
}
@@ -0,0 +1,13 @@
package com.ankurm.reviewbot;
import java.util.List;
/** The two things the bot needs from a Git host. Gitea and GitHub differ only inside the implementation. */
public interface GitHost {
record InlineComment(String path, int line, String body) {}
String fetchDiff(String repo, int pullNumber);
void postReview(String repo, int pullNumber, String commitSha, String summary, List<InlineComment> comments);
}
@@ -0,0 +1,64 @@
package com.ankurm.reviewbot;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.stream.Collectors;
/**
* Gitea implementation. The two endpoints were checked against this server's own swagger document
* (Gitea 1.27.3): {@code GET /repos/{owner}/{repo}/pulls/{index}.diff} and
* {@code POST /repos/{owner}/{repo}/pulls/{index}/reviews} with {@code comments[].new_position}.
* It is compiled and used by the application but is not exercised against a live server by the tests,
* because posting a review to a real repository is a side effect a test suite should not have.
*/
public final class GiteaHost implements GitHost {
private final HttpClient http = HttpClient.newHttpClient();
private final String apiBase;
private final String token;
public GiteaHost(String apiBase, String token) {
this.apiBase = apiBase;
this.token = token;
}
@Override
public String fetchDiff(String repo, int pullNumber) {
var req = HttpRequest.newBuilder(URI.create(apiBase + "/repos/" + repo + "/pulls/" + pullNumber + ".diff"))
.header("Authorization", "token " + token).GET().build();
return send(req);
}
@Override
public void postReview(String repo, int pullNumber, String commitSha, String summary, List<InlineComment> comments) {
String items = comments.stream()
.map(c -> "{\"path\":" + quote(c.path()) + ",\"new_position\":" + c.line() + ",\"body\":" + quote(c.body()) + "}")
.collect(Collectors.joining(","));
String json = "{\"commit_id\":" + quote(commitSha) + ",\"event\":\"COMMENT\",\"body\":" + quote(summary)
+ ",\"comments\":[" + items + "]}";
var req = HttpRequest.newBuilder(URI.create(apiBase + "/repos/" + repo + "/pulls/" + pullNumber + "/reviews"))
.header("Authorization", "token " + token).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json)).build();
send(req);
}
private String send(HttpRequest req) {
try {
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() / 100 != 2) {
throw new IllegalStateException("Gitea answered " + res.statusCode() + ": " + res.body());
}
return res.body();
} catch (IOException | InterruptedException e) {
throw new IllegalStateException(e);
}
}
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
}
}
@@ -0,0 +1,33 @@
package com.ankurm.reviewbot;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
/** Webhook signature check. Gitea sends the bare lowercase hex HMAC-SHA256 of the raw body. */
public final class Hmac {
private Hmac() {}
public static String sha256Hex(String secret, byte[] body) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(body));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/** Constant-time comparison, so a wrong signature does not leak how many characters matched. */
public static boolean matches(String secret, byte[] body, String headerValue) {
if (headerValue == null) {
return false;
}
byte[] expected = sha256Hex(secret, body).getBytes(StandardCharsets.UTF_8);
byte[] actual = headerValue.trim().toLowerCase().getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expected, actual);
}
}
@@ -0,0 +1,38 @@
package com.ankurm.reviewbot;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Wiring for a real deployment. Add a Spring AI model starter (for example
* spring-ai-starter-model-openai) to get a {@link ChatModel} bean; the tests in this module use a scripted one.
*/
@SpringBootApplication
public class ReviewBotApplication {
public static void main(String[] args) {
SpringApplication.run(ReviewBotApplication.class, args);
}
@Bean
GitHost gitHost(@Value("${gitea.api}") String api, @Value("${gitea.token}") String token) {
return new GiteaHost(api, token);
}
@Bean
ReviewService reviewService(ChatModel model, GitHost host) {
return new ReviewService(ChatClient.create(model), host, new ReviewService.Settings(2_000, 10, true));
}
@Bean
Executor reviewExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@@ -0,0 +1,105 @@
package com.ankurm.reviewbot;
import org.springframework.ai.chat.client.ChatClient;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Chunk the diff, ask the model about each chunk, keep only comments that can be attached, post one review.
*
* <p>Cost control lives here, in four places: ignored files never reach the model, a hard cap on
* chunks per review, a cache keyed by the chunk's content so an unchanged chunk is never paid for twice
* when a push re-triggers the review, and a severity filter so nits are dropped.
*/
public final class ReviewService {
public static final String PROMPT_VERSION = "v1";
static final String SYSTEM = """
You review a code diff. Each line is "<new-file-line>| <+ added | - removed | space context><code>".
Reply with zero or more lines in exactly this form and nothing else:
<line>|<MAJOR or MINOR or NIT>|<one sentence>
Only comment on lines that start with +. Reply NONE when there is nothing worth saying.
""";
public record Settings(int maxChunkChars, int maxChunks, boolean dropNits) {}
public record Summary(int chunksTotal, int skippedFiles, int skippedByBudget, int cacheHits, int modelCalls,
int commentsPosted, int droppedBadLine, int droppedNits, int approxInputChars) {}
private final ChatClient chat;
private final GitHost host;
private final Settings settings;
private final Map<String, List<String>> cache = new ConcurrentHashMap<>();
public ReviewService(ChatClient chat, GitHost host, Settings settings) {
this.chat = chat;
this.host = host;
this.settings = settings;
}
public Summary review(String repo, int pull, String headSha) {
String diff = host.fetchDiff(repo, pull);
var result = new DiffChunker(settings.maxChunkChars()).chunk(diff);
List<GitHost.InlineComment> comments = new ArrayList<>();
int calls = 0, hits = 0, badLine = 0, nits = 0, chars = 0;
int budgeted = Math.min(result.chunks().size(), settings.maxChunks());
for (int i = 0; i < budgeted; i++) {
var chunk = result.chunks().get(i);
String key = sha256(PROMPT_VERSION + "\n" + chunk.file() + "\n" + chunk.text());
List<String> replies = cache.get(key);
if (replies != null) {
hits++;
} else {
String answer = chat.prompt().system(SYSTEM).user("File: " + chunk.file() + "\n" + chunk.text())
.call().content();
calls++;
chars += SYSTEM.length() + chunk.text().length();
replies = List.of(answer == null ? new String[0] : answer.split("\n"));
cache.put(key, replies);
}
for (String line : replies) {
String[] p = line.split("\\|", 3);
if (p.length != 3) {
continue; // NONE, blank, or prose: ignore
}
int n;
try {
n = Integer.parseInt(p[0].trim());
} catch (NumberFormatException e) {
continue;
}
if (!chunk.addedLines().contains(n)) {
badLine++; // a line the model invented or a context line
continue;
}
if (settings.dropNits() && p[1].trim().equals("NIT")) {
nits++;
continue;
}
comments.add(new GitHost.InlineComment(chunk.file(), n, "[" + p[1].trim() + "] " + p[2].trim()));
}
}
if (!comments.isEmpty()) {
host.postReview(repo, pull, headSha, "Automated review: " + comments.size() + " comment(s).", comments);
}
return new Summary(result.chunks().size(), result.skippedFiles(),
result.chunks().size() - budgeted, hits, calls, comments.size(), badLine, nits, chars);
}
private static String sha256(String s) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,53 @@
package com.ankurm.reviewbot;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executor;
/**
* Receives Gitea webhooks. The body is taken as raw bytes because the signature is computed over
* exactly those bytes: parse first and re-serialise, and the HMAC no longer matches.
*/
@RestController
public class WebhookController {
private final JsonMapper json = JsonMapper.builder().build();
private final String secret;
private final Executor executor;
private final ReviewService service;
public WebhookController(@Value("${webhook.secret}") String secret, Executor reviewExecutor, ReviewService service) {
this.secret = secret;
this.executor = reviewExecutor;
this.service = service;
}
@PostMapping("/webhook/gitea")
public ResponseEntity<String> receive(@RequestHeader(value = "X-Gitea-Signature", required = false) String signature,
@RequestHeader(value = "X-Gitea-Event", required = false) String event,
@RequestBody byte[] body) {
if (!Hmac.matches(secret, body, signature)) {
return ResponseEntity.status(401).body("bad signature");
}
JsonNode root = json.readTree(new String(body, StandardCharsets.UTF_8));
String action = root.path("action").asString("");
boolean wanted = ("pull_request".equals(event) && (action.equals("opened") || action.equals("reopened")))
|| ("pull_request_sync".equals(event) && action.equals("synchronized"));
if (!wanted) {
return ResponseEntity.ok("ignored: " + event + "/" + action);
}
String repo = root.path("repository").path("full_name").asString();
int number = root.path("pull_request").path("number").asInt();
String sha = root.path("pull_request").path("head").path("sha").asString();
executor.execute(() -> service.review(repo, number, sha)); // answer fast, review in the background
return ResponseEntity.accepted().body("queued " + repo + "#" + number);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.reviewbot;
import java.util.ArrayList;
import java.util.List;
final class Fixtures {
private Fixtures() {}
/** A small PR: one Java file with two hunks, one lockfile, and a context line at new line 1. */
static final String DIFF = """
diff --git a/src/Login.java b/src/Login.java
index 111..222 100644
--- a/src/Login.java
+++ b/src/Login.java
@@ -1,4 +1,7 @@
class Login {
+ String password = "hunter2";
+ // TODO move to vault
void run() {
+ System.out.println("login");
}
}
diff --git a/package-lock.json b/package-lock.json
index 333..444 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,2 +1,3 @@
{
+ "lockfileVersion": 3,
}
""";
/** The webhook body Gitea sends for pull_request_sync, trimmed to the fields the bot reads. */
static final String PAYLOAD = """
{"action":"synchronized","repository":{"full_name":"asmhatre/demo"},
"pull_request":{"number":7,"head":{"sha":"abc123"}}}""";
/** A synthetic diff with {@code files} files of one added line each, for the budget test. */
static String manyFiles(int files) {
List<String> parts = new ArrayList<>();
for (int i = 1; i <= files; i++) {
parts.add("diff --git a/F" + i + ".java b/F" + i + ".java\n--- a/F" + i + ".java\n+++ b/F" + i
+ ".java\n@@ -0,0 +1,1 @@\n+System.out.println(" + i + ");");
}
return String.join("\n", parts) + "\n";
}
}
@@ -0,0 +1,25 @@
package com.ankurm.reviewbot;
import java.util.ArrayList;
import java.util.List;
/** Serves a canned diff and records the reviews the bot posts. */
final class RecordingGitHost implements GitHost {
String diff;
final List<InlineComment> posted = new ArrayList<>();
int reviewsPosted;
String lastCommit;
RecordingGitHost(String diff) { this.diff = diff; }
@Override public String fetchDiff(String repo, int pullNumber) { return diff; }
@Override
public void postReview(String repo, int pullNumber, String commitSha, String summary, List<InlineComment> comments) {
reviewsPosted++;
lastCommit = commitSha;
posted.clear();
posted.addAll(comments);
}
}
@@ -0,0 +1,175 @@
package com.ankurm.reviewbot;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
class ReviewBotTest {
private static ReviewService service(ScriptedChatModel model, RecordingGitHost host, int maxChunks, boolean dropNits) {
return new ReviewService(ChatClient.create(model), host, new ReviewService.Settings(2_000, maxChunks, dropNits));
}
@Test
void chunkerNumbersLinesOfTheNewFileAndSkipsLockfiles() throws Exception {
var t = new Transcript("01-diff-chunks.txt");
var r = new DiffChunker(2_000).chunk(Fixtures.DIFF);
t.line("chunks: " + r.chunks().size() + ", skipped files: " + r.skippedFiles());
for (var c : r.chunks()) {
t.line("file " + c.file() + " added lines " + c.addedLines());
c.text().lines().forEach(l -> t.line(" " + l));
}
t.write();
assertEquals(1, r.chunks().size());
assertEquals(1, r.skippedFiles());
assertEquals(java.util.Set.of(2, 3, 5), r.chunks().get(0).addedLines());
}
@Test
void chunkerNeverExceedsTheCharacterBudgetOrSplitsALine() throws Exception {
var t = new Transcript("02-chunk-size-limit.txt");
StringBuilder diff = new StringBuilder("diff --git a/Big.java b/Big.java\n--- a/Big.java\n+++ b/Big.java\n@@ -0,0 +1,40 @@\n");
for (int i = 0; i < 40; i++) {
diff.append("+int v").append(i).append(" = ").append(i).append(";\n");
}
var r = new DiffChunker(200).chunk(diff.toString());
for (int i = 0; i < r.chunks().size(); i++) {
t.line("chunk " + (i + 1) + ": " + r.chunks().get(i).text().length() + " chars, lines "
+ r.chunks().get(i).addedLines().iterator().next() + ".." + r.chunks().get(i).addedLines().stream().reduce((a, b) -> b).get());
}
t.line("chunks: " + r.chunks().size());
t.write();
assertTrue(r.chunks().size() > 1);
r.chunks().forEach(c -> assertTrue(c.text().length() <= 200));
}
@Test
void reviewPostsOnlyCommentsThatCanBeAttachedAndDropsNits() throws Exception {
var t = new Transcript("03-review-end-to-end.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.DIFF);
var s = service(model, host, 10, true).review("asmhatre/demo", 7, "abc123");
host.posted.forEach(c -> t.line("comment " + c.path() + ":" + c.line() + " " + c.body()));
t.line("summary: " + s);
t.write();
assertEquals(2, host.posted.size());
assertEquals(2, host.posted.get(0).line());
assertTrue(host.posted.get(0).body().startsWith("[MAJOR]"));
assertEquals(5, host.posted.get(1).line());
assertEquals(1, s.droppedNits());
assertEquals("abc123", host.lastCommit);
}
@Test
void commentsOnLinesTheModelInventedAreDroppedNotPosted() throws Exception {
var t = new Transcript("04-hallucinated-lines-dropped.txt");
var model = new ScriptedChatModel();
model.hallucinate = true;
var host = new RecordingGitHost(Fixtures.DIFF);
var s = service(model, host, 10, true).review("asmhatre/demo", 7, "abc123");
host.posted.forEach(c -> t.line("posted line " + c.line()));
t.line("dropped because the line is not an added line: " + s.droppedBadLine());
t.write();
assertEquals(2, s.droppedBadLine());
assertTrue(host.posted.stream().noneMatch(c -> c.line() == 9999 || c.line() == 1));
}
@Test
void anUnchangedChunkIsNotPaidForTwice() throws Exception {
var t = new Transcript("05-cache-saves-model-calls.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.manyFiles(3));
var svc = service(model, host, 10, true);
var first = svc.review("asmhatre/demo", 8, "sha1");
var again = svc.review("asmhatre/demo", 8, "sha2"); // a push that changed nothing in the diff
host.diff = Fixtures.manyFiles(3).replace("println(3)", "println(33)");
var changed = svc.review("asmhatre/demo", 8, "sha3"); // one chunk changed
t.line("first review: model calls " + first.modelCalls() + ", cache hits " + first.cacheHits());
t.line("same diff again: model calls " + again.modelCalls() + ", cache hits " + again.cacheHits());
t.line("one file edited: model calls " + changed.modelCalls() + ", cache hits " + changed.cacheHits());
t.write();
assertEquals(3, first.modelCalls());
assertEquals(0, again.modelCalls());
assertEquals(3, again.cacheHits());
assertEquals(1, changed.modelCalls());
}
@Test
void theChunkCapStopsTheSpend() throws Exception {
var t = new Transcript("06-chunk-cap.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.manyFiles(8));
var s = service(model, host, 3, true).review("asmhatre/demo", 9, "sha");
t.line("chunks in diff: " + s.chunksTotal());
t.line("model calls: " + s.modelCalls());
t.line("chunks skipped by the cap: " + s.skippedByBudget());
t.line("approximate characters sent: " + s.approxInputChars());
t.write();
assertEquals(8, s.chunksTotal());
assertEquals(3, s.modelCalls());
assertEquals(5, s.skippedByBudget());
}
@Test
void webhookChecksTheSignatureAndFiltersEvents() throws Exception {
var t = new Transcript("07-webhook.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.DIFF);
List<Runnable> queued = new ArrayList<>();
Executor capture = queued::add;
var controller = new WebhookController("s3cret", capture, service(model, host, 10, true));
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
byte[] body = Fixtures.PAYLOAD.getBytes(StandardCharsets.UTF_8);
String good = Hmac.sha256Hex("s3cret", body);
int bad = mvc.perform(post("/webhook/gitea").header("X-Gitea-Signature", "00" + good.substring(2))
.header("X-Gitea-Event", "pull_request_sync").contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse().getStatus();
int none = mvc.perform(post("/webhook/gitea").header("X-Gitea-Event", "pull_request_sync")
.contentType(MediaType.APPLICATION_JSON).content(body)).andReturn().getResponse().getStatus();
var ignored = mvc.perform(post("/webhook/gitea").header("X-Gitea-Signature", good)
.header("X-Gitea-Event", "pull_request_comment").contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse();
var ok = mvc.perform(post("/webhook/gitea").header("X-Gitea-Signature", good)
.header("X-Gitea-Event", "pull_request_sync").contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse();
t.line("wrong signature -> " + bad);
t.line("no signature -> " + none);
t.line("comment event -> " + ignored.getStatus() + " " + ignored.getContentAsString());
t.line("valid sync event -> " + ok.getStatus() + " " + ok.getContentAsString());
t.line("jobs queued: " + queued.size() + ", reviews posted before the job runs: " + host.reviewsPosted);
queued.forEach(Runnable::run);
t.line("reviews posted after the job runs: " + host.reviewsPosted);
t.write();
assertEquals(401, bad);
assertEquals(401, none);
assertEquals(200, ignored.getStatus());
assertEquals(202, ok.getStatus());
assertEquals(1, queued.size());
assertEquals(1, host.reviewsPosted);
}
}
@@ -0,0 +1,53 @@
package com.ankurm.reviewbot;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.List;
/**
* A stand-in reviewer, so the bot runs with no API key. It flags three things on lines that start
* with "+": System.out.println (MINOR), a hard-coded password (MAJOR), and TODO (NIT). When
* {@code hallucinate} is set it also invents a comment on line 9999 and on a context line.
*/
final class ScriptedChatModel implements ChatModel {
final List<String> prompts = new ArrayList<>();
boolean hallucinate;
@Override
public ChatResponse call(Prompt prompt) {
String text = prompt.getContents();
prompts.add(text);
StringBuilder out = new StringBuilder();
for (String line : text.split("\n")) {
int bar = line.indexOf("| +");
if (bar <= 0) {
continue;
}
String n = line.substring(0, bar).trim();
String code = line.substring(bar + 3);
if (code.contains("System.out.println")) {
out.append(n).append("|MINOR|Use a logger instead of System.out.\n");
}
if (code.toLowerCase().contains("password") && code.contains("=")) {
out.append(n).append("|MAJOR|This looks like a hard-coded credential.\n");
}
if (code.contains("TODO")) {
out.append(n).append("|NIT|Track this TODO in an issue.\n");
}
}
if (hallucinate) {
out.append("9999|MAJOR|A comment on a line that is not in this diff.\n");
out.append("1|MAJOR|A comment on a context line the model was told not to touch.\n");
}
if (out.length() == 0) {
out.append("NONE");
}
return new ChatResponse(List.of(new Generation(new AssistantMessage(out.toString()))));
}
}
@@ -0,0 +1,23 @@
package com.ankurm.reviewbot;
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);
}
}
+19
View File
@@ -0,0 +1,19 @@
# langgraph4j
Companion code for *LangGraph4j: Stateful Agent Graphs in Java* on [ankurm.com](https://ankurm.com).
A loan-approval agent drawn as a graph with conditional routing, checkpoints, a human-in-the-loop
pause and resume, and per-thread state. No model and no API key: every node is deterministic so the
run is reproducible; swapping `assessRisk` for a model call changes one lambda.
```bash
mvn test -pl langgraph4j # rewrites output/*.txt
```
| File | Role |
|---|---|
| `LoanState` | The shared state: typed accessors over a map, with an appender channel for the audit trail |
| `LoanGraph` | Nodes, edges, the conditional route on risk, `interruptBefore("humanReview")`, the checkpoint saver |
| `LoanGraphTest` | Five tests; each writes the matching `output/NN-*.txt` and asserts the same facts |
Verified against `org.bsc.langgraph4j:langgraph4j-core` **1.9.5**, JDK 25, JUnit 6.1.3. The article carries
the explanations; there is no `docs/` folder.
@@ -0,0 +1,14 @@
__START__ decision=PENDING
intake decision=PENDING
creditCheck decision=PENDING
assessRisk decision=PENDING
autoApprove decision=APPROVED
notify decision=APPROVED
__END__ decision=APPROVED
final decision: APPROVED
trail:
intake: Asha asks for 40000
creditCheck: score 850
assessRisk: ratio 0.33 -> LOW
autoApprove: approved by policy
notify: told Asha the result is APPROVED
@@ -0,0 +1,17 @@
--- first run ---
__START__ decision=PENDING
intake decision=PENDING
creditCheck decision=PENDING
assessRisk decision=PENDING
paused, next node: humanReview
decision while paused: PENDING
--- resumed after reviewer wrote decision=APPROVED ---
humanReview decision=APPROVED
notify decision=APPROVED
__END__ decision=APPROVED
final decision: APPROVED
intake: Ravi asks for 300000
creditCheck: score 750
assessRisk: ratio 3.33 -> MEDIUM
humanReview: reviewer decided APPROVED
notify: told Ravi the result is APPROVED
@@ -0,0 +1,8 @@
checkpoints saved: 6
next=__END__ trail-size=5
next=notify trail-size=4
next=autoReject trail-size=3
next=assessRisk trail-size=2
next=creditCheck trail-size=1
next=intake trail-size=0
final decision: REJECTED
@@ -0,0 +1,2 @@
thread A: Asha -> APPROVED
thread B: Mohan -> REJECTED
@@ -0,0 +1,2 @@
invoke returned a state: true, decision=APPROVED
getState after the run finished: IllegalStateException: Missing Checkpoint!
+28
View File
@@ -0,0 +1,28 @@
<?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>langgraph4j</artifactId>
<name>LangGraph4j loan-approval agent</name>
<dependencies>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-core</artifactId>
<version>${langgraph4j.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,88 @@
package com.ankurm.lg4j;
import org.bsc.langgraph4j.CompileConfig;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.GraphStateException;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver;
import org.bsc.langgraph4j.checkpoint.MemorySaver;
import java.util.Map;
import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.StateGraph.START;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
/**
* A loan-approval agent drawn as a graph.
*
* <pre>
* START -> intake -> creditCheck -> assessRisk --LOW-----> autoApprove --+
* --HIGH----> autoReject --+--> notify -> END
* --MEDIUM--> humanReview --+
* </pre>
*
* <p>The graph is compiled with {@code interruptBefore("humanReview")}, so a MEDIUM-risk
* application pauses with a checkpoint instead of deciding on its own. There is no LLM here
* on purpose: every node is deterministic so the run is reproducible. Swapping
* {@code assessRisk} for a model call changes one lambda, not the graph.
*/
public final class LoanGraph {
private LoanGraph() {}
public static CompiledGraph<LoanState> build(BaseCheckpointSaver saver, boolean releaseThread) throws GraphStateException {
var graph = new StateGraph<>(LoanState.SCHEMA, LoanState::new)
.addNode("intake", node_async(s -> Map.of(
LoanState.TRAIL, "intake: " + s.applicant() + " asks for " + s.amount())))
.addNode("creditCheck", node_async(s -> {
int score = 300 + (s.income() / 1000) * 5; // stand-in for a bureau call
return Map.of(
LoanState.CREDIT_SCORE, Math.min(score, 850),
LoanState.TRAIL, "creditCheck: score " + Math.min(score, 850));
}))
.addNode("assessRisk", node_async(s -> {
double ratio = (double) s.amount() / Math.max(1, s.income());
String risk = (s.creditScore() >= 700 && ratio < 1.0) ? "LOW"
: (s.creditScore() < 550 || ratio > 4.0) ? "HIGH" : "MEDIUM";
return Map.of(
LoanState.RISK, risk,
LoanState.TRAIL, "assessRisk: ratio " + String.format("%.2f", ratio) + " -> " + risk);
}))
.addNode("autoApprove", node_async(s -> Map.of(
LoanState.DECISION, "APPROVED", LoanState.TRAIL, "autoApprove: approved by policy")))
.addNode("autoReject", node_async(s -> Map.of(
LoanState.DECISION, "REJECTED", LoanState.TRAIL, "autoReject: rejected by policy")))
.addNode("humanReview", node_async(s -> Map.of(
LoanState.TRAIL, "humanReview: reviewer decided " + s.decision())))
.addNode("notify", node_async(s -> Map.of(
LoanState.TRAIL, "notify: told " + s.applicant() + " the result is " + s.decision())))
.addEdge(START, "intake")
.addEdge("intake", "creditCheck")
.addEdge("creditCheck", "assessRisk")
.addConditionalEdges("assessRisk",
edge_async(s -> s.risk()),
Map.of("LOW", "autoApprove", "HIGH", "autoReject", "MEDIUM", "humanReview"))
.addEdge("autoApprove", "notify")
.addEdge("autoReject", "notify")
.addEdge("humanReview", "notify")
.addEdge("notify", END);
var config = CompileConfig.builder()
.checkpointSaver(saver)
.interruptBefore("humanReview")
.releaseThread(releaseThread)
.build();
return graph.compile(config);
}
/** Keeps checkpoints after a run finishes, so getState / getStateHistory still work. */
public static CompiledGraph<LoanState> build() throws GraphStateException {
return build(new MemorySaver(), false);
}
public static Map<String, Object> application(String name, int amount, int income) {
return Map.of(LoanState.APPLICANT, name, LoanState.AMOUNT, amount, LoanState.INCOME, income);
}
}
@@ -0,0 +1,43 @@
package com.ankurm.lg4j;
import org.bsc.langgraph4j.state.AgentState;
import org.bsc.langgraph4j.state.Channel;
import org.bsc.langgraph4j.state.Channels;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* The shared state every node reads and writes.
*
* <p>State is just a {@code Map<String,Object>} with typed accessors. A key with an
* <em>appender</em> channel ({@code trail}) accumulates values; any other key is
* overwritten by the latest node that writes it.
*/
public class LoanState extends AgentState {
public static final String APPLICANT = "applicant";
public static final String AMOUNT = "amount";
public static final String INCOME = "income";
public static final String CREDIT_SCORE = "creditScore";
public static final String RISK = "risk";
public static final String DECISION = "decision";
public static final String TRAIL = "trail";
/** Channel schema: {@code trail} appends, everything else replaces. */
public static final Map<String, Channel<?>> SCHEMA =
Map.of(TRAIL, Channels.appender(ArrayList::new));
public LoanState(Map<String, Object> data) {
super(data);
}
public String applicant() { return this.<String>value(APPLICANT).orElse("?"); }
public int amount() { return this.<Integer>value(AMOUNT).orElse(0); }
public int income() { return this.<Integer>value(INCOME).orElse(0); }
public int creditScore() { return this.<Integer>value(CREDIT_SCORE).orElse(0); }
public String risk() { return this.<String>value(RISK).orElse("UNKNOWN"); }
public String decision() { return this.<String>value(DECISION).orElse("PENDING"); }
public List<String> trail() { return this.<List<String>>value(TRAIL).orElse(List.of()); }
}
@@ -0,0 +1,115 @@
package com.ankurm.lg4j;
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.NodeOutput;
import org.bsc.langgraph4j.RunnableConfig;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LoanGraphTest {
@Test
void lowRiskIsApprovedWithoutAHuman() throws Exception {
var t = new Transcript("01-low-risk-straight-through.txt");
var app = LoanGraph.build();
var cfg = RunnableConfig.builder().threadId("low").build();
for (NodeOutput<LoanState> step : app.stream(LoanGraph.application("Asha", 40_000, 120_000), cfg)) {
t.line(String.format("%-12s decision=%s", step.node(), step.state().decision()));
}
var last = app.getState(cfg).state();
t.line("final decision: " + last.decision());
t.line("trail:");
last.trail().forEach(x -> t.line(" " + x));
t.write();
assertEquals("APPROVED", last.decision());
assertEquals("LOW", last.risk());
assertEquals(5, last.trail().size());
}
@Test
void mediumRiskPausesBeforeHumanReviewThenResumes() throws Exception {
var t = new Transcript("02-medium-risk-pause-and-resume.txt");
var app = LoanGraph.build();
var cfg = RunnableConfig.builder().threadId("medium").build();
t.line("--- first run ---");
for (NodeOutput<LoanState> step : app.stream(LoanGraph.application("Ravi", 300_000, 90_000), cfg)) {
t.line(String.format("%-12s decision=%s", step.node(), step.state().decision()));
}
var paused = app.getState(cfg);
t.line("paused, next node: " + paused.next());
t.line("decision while paused: " + paused.state().decision());
assertEquals("humanReview", paused.next());
assertEquals("PENDING", paused.state().decision());
// The human writes their decision into the state at the checkpoint, then we resume.
var cfg2 = app.updateState(cfg, java.util.Map.of(LoanState.DECISION, "APPROVED"));
t.line("--- resumed after reviewer wrote decision=APPROVED ---");
for (NodeOutput<LoanState> step : app.stream(GraphInput.resume(), cfg2)) {
t.line(String.format("%-12s decision=%s", step.node(), step.state().decision()));
}
var done = app.getState(cfg).state();
t.line("final decision: " + done.decision());
done.trail().forEach(x -> t.line(" " + x));
t.write();
assertEquals("APPROVED", done.decision());
assertTrue(done.trail().stream().anyMatch(x -> x.startsWith("humanReview:")));
}
@Test
void highRiskIsRejectedAndCheckpointsExistForEveryStep() throws Exception {
var t = new Transcript("03-high-risk-and-checkpoint-history.txt");
var app = LoanGraph.build();
var cfg = RunnableConfig.builder().threadId("high").build();
app.invoke(LoanGraph.application("Mohan", 900_000, 100_000), cfg);
var history = app.getStateHistory(cfg);
t.line("checkpoints saved: " + history.size());
history.forEach(h -> t.line(String.format(" next=%-12s trail-size=%d", h.next(), h.state().trail().size())));
t.line("final decision: " + app.getState(cfg).state().decision());
t.write();
assertEquals("REJECTED", app.getState(cfg).state().decision());
assertTrue(history.size() >= 5);
}
@Test
void threadsAreIsolated() throws Exception {
var t = new Transcript("04-thread-isolation.txt");
var app = LoanGraph.build();
var a = RunnableConfig.builder().threadId("A").build();
var b = RunnableConfig.builder().threadId("B").build();
app.invoke(LoanGraph.application("Asha", 40_000, 120_000), a);
app.invoke(LoanGraph.application("Mohan", 900_000, 100_000), b);
t.line("thread A: " + app.getState(a).state().applicant() + " -> " + app.getState(a).state().decision());
t.line("thread B: " + app.getState(b).state().applicant() + " -> " + app.getState(b).state().decision());
t.write();
assertEquals("APPROVED", app.getState(a).state().decision());
assertEquals("REJECTED", app.getState(b).state().decision());
}
@Test
void releasingTheThreadErasesTheStateOfAFinishedRun() throws Exception {
var t = new Transcript("05-release-thread-erases-finished-run.txt");
var app = LoanGraph.build(new org.bsc.langgraph4j.checkpoint.MemorySaver(), true);
var cfg = RunnableConfig.builder().threadId("released").build();
var result = app.invoke(LoanGraph.application("Asha", 40_000, 120_000), cfg);
t.line("invoke returned a state: " + result.isPresent() + ", decision=" + result.get().decision());
try {
app.getState(cfg);
t.line("getState: found a checkpoint");
fail("expected the checkpoint to be gone");
} catch (IllegalStateException e) {
t.line("getState after the run finished: IllegalStateException: " + e.getMessage());
t.write();
assertEquals("Missing Checkpoint!", e.getMessage());
}
}
}
@@ -0,0 +1,23 @@
package com.ankurm.lg4j;
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);
}
}
+7
View File
@@ -14,6 +14,9 @@
<module>a2a</module>
<module>jlama</module>
<module>onnx-djl</module>
<module>langgraph4j</module>
<module>adk</module>
<module>code-review-bot</module>
</modules>
<properties>
@@ -24,6 +27,10 @@
<a2a.sdk.version>1.0.0.Alpha3</a2a.sdk.version>
<jlama.version>0.8.4</jlama.version>
<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>
<spring-ai.version>2.0.1</spring-ai.version>
<onnxruntime.version>1.31.0</onnxruntime.version>
<spring-boot.version>4.1.1</spring-boot.version>
<junit.version>6.1.3</junit.version>