From 3b52a434f531494e274a12a2fb5f3106e5e8c59a Mon Sep 17 00:00:00 2001 From: asmhatre Date: Wed, 14 Oct 2026 06:47:05 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RjfyTgmERTWLqzafu34Cth --- README.md | 2 + langgraph4j/README.md | 19 +++ .../output/01-low-risk-straight-through.txt | 14 +++ .../02-medium-risk-pause-and-resume.txt | 17 +++ .../03-high-risk-and-checkpoint-history.txt | 8 ++ langgraph4j/output/04-thread-isolation.txt | 2 + .../05-release-thread-erases-finished-run.txt | 2 + langgraph4j/pom.xml | 28 +++++ .../main/java/com/ankurm/lg4j/LoanGraph.java | 88 ++++++++++++++ .../main/java/com/ankurm/lg4j/LoanState.java | 43 +++++++ .../java/com/ankurm/lg4j/LoanGraphTest.java | 115 ++++++++++++++++++ .../test/java/com/ankurm/lg4j/Transcript.java | 23 ++++ pom.xml | 3 + 13 files changed, 364 insertions(+) create mode 100644 langgraph4j/README.md create mode 100644 langgraph4j/output/01-low-risk-straight-through.txt create mode 100644 langgraph4j/output/02-medium-risk-pause-and-resume.txt create mode 100644 langgraph4j/output/03-high-risk-and-checkpoint-history.txt create mode 100644 langgraph4j/output/04-thread-isolation.txt create mode 100644 langgraph4j/output/05-release-thread-erases-finished-run.txt create mode 100644 langgraph4j/pom.xml create mode 100644 langgraph4j/src/main/java/com/ankurm/lg4j/LoanGraph.java create mode 100644 langgraph4j/src/main/java/com/ankurm/lg4j/LoanState.java create mode 100644 langgraph4j/src/test/java/com/ankurm/lg4j/LoanGraphTest.java create mode 100644 langgraph4j/src/test/java/com/ankurm/lg4j/Transcript.java diff --git a/README.md b/README.md index 95c6d88..0b055e6 100644 --- a/README.md +++ b/README.md @@ -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 | | diff --git a/langgraph4j/README.md b/langgraph4j/README.md new file mode 100644 index 0000000..d729b9b --- /dev/null +++ b/langgraph4j/README.md @@ -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. diff --git a/langgraph4j/output/01-low-risk-straight-through.txt b/langgraph4j/output/01-low-risk-straight-through.txt new file mode 100644 index 0000000..11bd09d --- /dev/null +++ b/langgraph4j/output/01-low-risk-straight-through.txt @@ -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 diff --git a/langgraph4j/output/02-medium-risk-pause-and-resume.txt b/langgraph4j/output/02-medium-risk-pause-and-resume.txt new file mode 100644 index 0000000..b2e1c85 --- /dev/null +++ b/langgraph4j/output/02-medium-risk-pause-and-resume.txt @@ -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 diff --git a/langgraph4j/output/03-high-risk-and-checkpoint-history.txt b/langgraph4j/output/03-high-risk-and-checkpoint-history.txt new file mode 100644 index 0000000..decf0bf --- /dev/null +++ b/langgraph4j/output/03-high-risk-and-checkpoint-history.txt @@ -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 diff --git a/langgraph4j/output/04-thread-isolation.txt b/langgraph4j/output/04-thread-isolation.txt new file mode 100644 index 0000000..79c9447 --- /dev/null +++ b/langgraph4j/output/04-thread-isolation.txt @@ -0,0 +1,2 @@ +thread A: Asha -> APPROVED +thread B: Mohan -> REJECTED diff --git a/langgraph4j/output/05-release-thread-erases-finished-run.txt b/langgraph4j/output/05-release-thread-erases-finished-run.txt new file mode 100644 index 0000000..36ca15d --- /dev/null +++ b/langgraph4j/output/05-release-thread-erases-finished-run.txt @@ -0,0 +1,2 @@ +invoke returned a state: true, decision=APPROVED +getState after the run finished: IllegalStateException: Missing Checkpoint! diff --git a/langgraph4j/pom.xml b/langgraph4j/pom.xml new file mode 100644 index 0000000..7cf4931 --- /dev/null +++ b/langgraph4j/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.ankurm.agents + java-ai-agents + 1.0.0 + + + langgraph4j + LangGraph4j loan-approval agent + + + + org.bsc.langgraph4j + langgraph4j-core + ${langgraph4j.version} + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/langgraph4j/src/main/java/com/ankurm/lg4j/LoanGraph.java b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanGraph.java new file mode 100644 index 0000000..cdfe509 --- /dev/null +++ b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanGraph.java @@ -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. + * + *
+ * START -> intake -> creditCheck -> assessRisk --LOW-----> autoApprove --+
+ *                                              --HIGH----> autoReject  --+--> notify -> END
+ *                                              --MEDIUM--> humanReview --+
+ * 
+ * + *

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 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 build() throws GraphStateException { + return build(new MemorySaver(), false); + } + + public static Map application(String name, int amount, int income) { + return Map.of(LoanState.APPLICANT, name, LoanState.AMOUNT, amount, LoanState.INCOME, income); + } +} diff --git a/langgraph4j/src/main/java/com/ankurm/lg4j/LoanState.java b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanState.java new file mode 100644 index 0000000..a9ba71e --- /dev/null +++ b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanState.java @@ -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. + * + *

State is just a {@code Map} with typed accessors. A key with an + * appender 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> SCHEMA = + Map.of(TRAIL, Channels.appender(ArrayList::new)); + + public LoanState(Map data) { + super(data); + } + + public String applicant() { return this.value(APPLICANT).orElse("?"); } + public int amount() { return this.value(AMOUNT).orElse(0); } + public int income() { return this.value(INCOME).orElse(0); } + public int creditScore() { return this.value(CREDIT_SCORE).orElse(0); } + public String risk() { return this.value(RISK).orElse("UNKNOWN"); } + public String decision() { return this.value(DECISION).orElse("PENDING"); } + public List trail() { return this.>value(TRAIL).orElse(List.of()); } +} diff --git a/langgraph4j/src/test/java/com/ankurm/lg4j/LoanGraphTest.java b/langgraph4j/src/test/java/com/ankurm/lg4j/LoanGraphTest.java new file mode 100644 index 0000000..5e2f101 --- /dev/null +++ b/langgraph4j/src/test/java/com/ankurm/lg4j/LoanGraphTest.java @@ -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 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 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 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()); + } + } +} diff --git a/langgraph4j/src/test/java/com/ankurm/lg4j/Transcript.java b/langgraph4j/src/test/java/com/ankurm/lg4j/Transcript.java new file mode 100644 index 0000000..28c7411 --- /dev/null +++ b/langgraph4j/src/test/java/com/ankurm/lg4j/Transcript.java @@ -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 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); + } +} diff --git a/pom.xml b/pom.xml index f34b42d..1e49008 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ a2a jlama onnx-djl + langgraph4j @@ -24,6 +25,8 @@ 1.0.0.Alpha3 0.8.4 0.38.0 + + 1.9.5 1.31.0 4.1.1 6.1.3