Add langgraph4j module: a loan-approval graph with checkpoints and human-in-the-loop on LangGraph4j 1.9.5

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:05 +00:00
co-authored by Claude Sonnet 5.5
parent 280cbe7d56
commit 3b52a434f5
13 changed files with 364 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ 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 |
## Versions (verified on Maven Central, 2026-10-11)
@@ -23,6 +24,7 @@ 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 |
| JDK | 25 LTS | |
| JUnit | 6.1.3 | |
+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);
}
}
+3
View File
@@ -14,6 +14,7 @@
<module>a2a</module>
<module>jlama</module>
<module>onnx-djl</module>
<module>langgraph4j</module>
</modules>
<properties>
@@ -24,6 +25,8 @@
<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>
<onnxruntime.version>1.31.0</onnxruntime.version>
<spring-boot.version>4.1.1</spring-boot.version>
<junit.version>6.1.3</junit.version>