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:
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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!
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user