Compare commits
3
Commits
280cbe7d56
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef782060dd | ||
|
|
d5725f66ba | ||
|
|
3b52a434f5 |
@@ -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 | |
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# adk
|
||||
|
||||
Companion code for *Google Agent Development Kit (ADK) for Java: Your First Multi-Agent App* on
|
||||
[ankurm.com](https://ankurm.com). Two `LlmAgent`s in a `SequentialAgent`: a researcher that calls a tool and
|
||||
saves its answer to session state, and a writer whose instruction reads that state.
|
||||
|
||||
There is no Gemini key here. `ScriptedLlm` is a small `BaseLlm` that behaves like a tool-calling model, so the
|
||||
agents, the tool call, the session and the state hand-off all run for real while the model is the one
|
||||
scripted part.
|
||||
|
||||
```bash
|
||||
mvn test -pl adk # rewrites output/*.txt
|
||||
```
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `OrderTools` | The tool: a public static method with `@Schema` parameter annotations |
|
||||
| `ScriptedLlm` | The stand-in model; records every `LlmRequest` it receives |
|
||||
| `SupportPipeline` | The two agents and the `SequentialAgent` that runs them |
|
||||
| `SupportPipelineTest` | Three tests; each writes `output/NN-*.txt`. Map entries inside tool results print in a different order from run to run, so compare content, not line order |
|
||||
|
||||
Verified against `com.google.adk:google-adk` **1.11.0**, JDK 25, JUnit 6.1.3.
|
||||
@@ -0,0 +1,6 @@
|
||||
researcher call lookupOrder{orderId=A-1001}
|
||||
researcher result lookupOrder{orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
|
||||
researcher text: Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
|
||||
writer text: Hello, here is your update. Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
|
||||
session state keys: [findings]
|
||||
state[findings] = Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
|
||||
@@ -0,0 +1,5 @@
|
||||
model calls made: 3
|
||||
call 1: tools offered = [lookupOrder]
|
||||
call 2: tools offered = [lookupOrder]
|
||||
call 3: tools offered = []
|
||||
writer system instruction contains the findings: true
|
||||
@@ -0,0 +1,2 @@
|
||||
user-1 events: 5, keys: [findings]
|
||||
user-2 events: 0, keys: []
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm.agents</groupId>
|
||||
<artifactId>java-ai-agents</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>adk</artifactId>
|
||||
<name>Google ADK for Java: two-agent support pipeline</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.adk</groupId>
|
||||
<artifactId>google-adk</artifactId>
|
||||
<version>${adk.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-nop</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.adk;
|
||||
|
||||
import com.google.adk.tools.Annotations.Schema;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The tool the "researcher" agent can call. ADK turns a public static method into a function
|
||||
* declaration for the model: the method name becomes the tool name and each {@code @Schema}
|
||||
* annotation becomes a parameter description the model sees.
|
||||
*/
|
||||
public final class OrderTools {
|
||||
|
||||
private OrderTools() {}
|
||||
|
||||
private static final Map<String, String> ORDERS = Map.of(
|
||||
"A-1001", "shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15",
|
||||
"A-1002", "payment pending, no shipment yet");
|
||||
|
||||
/** Looks up an order. The model must pass the order id as a string. */
|
||||
public static Map<String, Object> lookupOrder(
|
||||
@Schema(name = "orderId", description = "The order id such as A-1001") String orderId) {
|
||||
String status = ORDERS.get(orderId);
|
||||
if (status == null) {
|
||||
return Map.of("status", "error", "message", "no order " + orderId);
|
||||
}
|
||||
return Map.of("status", "ok", "orderId", orderId, "detail", status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ankurm.adk;
|
||||
|
||||
import com.google.adk.models.BaseLlm;
|
||||
import com.google.adk.models.BaseLlmConnection;
|
||||
import com.google.adk.models.LlmRequest;
|
||||
import com.google.adk.models.LlmResponse;
|
||||
import com.google.genai.types.Content;
|
||||
import com.google.genai.types.Part;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A stand-in for Gemini so the whole pipeline runs with no API key and no network.
|
||||
*
|
||||
* <p>It does what a tool-calling model does, in the simplest possible way:
|
||||
* <ol>
|
||||
* <li>if the conversation already holds a function response, it answers in plain text;</li>
|
||||
* <li>otherwise, if the request carries a tool, it asks to call that tool;</li>
|
||||
* <li>otherwise it writes text from its system instruction.</li>
|
||||
* </ol>
|
||||
* Every request it receives is recorded so a test can inspect what ADK actually sent.
|
||||
*/
|
||||
public final class ScriptedLlm extends BaseLlm {
|
||||
|
||||
public final List<LlmRequest> seen = new ArrayList<>();
|
||||
|
||||
public ScriptedLlm() {
|
||||
super("scripted-model");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flowable<LlmResponse> generateContent(LlmRequest request, boolean stream) {
|
||||
seen.add(request);
|
||||
Content last = request.contents().isEmpty() ? null : request.contents().get(request.contents().size() - 1);
|
||||
boolean hasToolResult = last != null && last.parts().isPresent()
|
||||
&& last.parts().get().stream().anyMatch(p -> p.functionResponse().isPresent());
|
||||
|
||||
Content reply;
|
||||
if (hasToolResult) {
|
||||
String result = last.parts().get().stream()
|
||||
.map(p -> p.functionResponse().get().response().orElse(Map.of()).toString())
|
||||
.findFirst().orElse("");
|
||||
reply = text("Findings: " + result);
|
||||
} else if (!request.tools().isEmpty()) {
|
||||
String tool = request.tools().keySet().iterator().next();
|
||||
reply = Content.builder().role("model")
|
||||
.parts(Part.fromFunctionCall(tool, Map.of("orderId", "A-1001"))).build();
|
||||
} else {
|
||||
String instruction = request.getFirstSystemInstruction().orElse("");
|
||||
int at = instruction.indexOf("Findings:");
|
||||
String facts = "(no findings were passed in)";
|
||||
if (at >= 0) {
|
||||
int end = instruction.indexOf('\n', at); // ADK appends an identity paragraph after ours
|
||||
facts = end > 0 ? instruction.substring(at, end) : instruction.substring(at);
|
||||
}
|
||||
reply = text("Hello, here is your update. " + facts);
|
||||
}
|
||||
return Flowable.just(LlmResponse.builder().content(reply).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BaseLlmConnection connect(LlmRequest request) {
|
||||
throw new UnsupportedOperationException("live connections are not needed here");
|
||||
}
|
||||
|
||||
private static Content text(String s) {
|
||||
return Content.builder().role("model").parts(Part.fromText(s)).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.adk;
|
||||
|
||||
import com.google.adk.agents.BaseAgent;
|
||||
import com.google.adk.agents.LlmAgent;
|
||||
import com.google.adk.agents.SequentialAgent;
|
||||
import com.google.adk.models.BaseLlm;
|
||||
import com.google.adk.tools.FunctionTool;
|
||||
|
||||
/**
|
||||
* Two agents in a fixed order:
|
||||
* <pre>
|
||||
* researcher (has the lookupOrder tool, saves its answer under state key "findings")
|
||||
* |
|
||||
* writer (no tools, its instruction reads {findings} from session state)
|
||||
* </pre>
|
||||
* A {@link SequentialAgent} runs its sub-agents one after the other in the same session, so the
|
||||
* shared session state is how the first agent hands work to the second.
|
||||
*/
|
||||
public final class SupportPipeline {
|
||||
|
||||
private SupportPipeline() {}
|
||||
|
||||
public static BaseAgent build(BaseLlm model) {
|
||||
LlmAgent researcher = LlmAgent.builder()
|
||||
.name("researcher")
|
||||
.description("Looks up the order the customer asks about.")
|
||||
.model(model)
|
||||
.instruction("Find the order the customer mentions by calling the lookupOrder tool.")
|
||||
.tools(FunctionTool.create(OrderTools.class, "lookupOrder"))
|
||||
.outputKey("findings")
|
||||
.build();
|
||||
|
||||
LlmAgent writer = LlmAgent.builder()
|
||||
.name("writer")
|
||||
.description("Writes the customer reply from the research.")
|
||||
.model(model)
|
||||
.instruction("Write a short friendly reply to the customer using only this: {findings}")
|
||||
.build();
|
||||
|
||||
return SequentialAgent.builder()
|
||||
.name("support_pipeline")
|
||||
.description("Research the order, then write the reply.")
|
||||
.subAgents(researcher, writer)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.ankurm.adk;
|
||||
|
||||
import com.google.adk.agents.BaseAgent;
|
||||
import com.google.adk.events.Event;
|
||||
import com.google.adk.runner.InMemoryRunner;
|
||||
import com.google.adk.sessions.Session;
|
||||
import com.google.genai.types.Content;
|
||||
import com.google.genai.types.Part;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SupportPipelineTest {
|
||||
|
||||
private static final String APP = "support-app";
|
||||
|
||||
private static String describe(Event e) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
e.content().flatMap(Content::parts).ifPresent(parts -> {
|
||||
for (Part p : parts) {
|
||||
p.text().ifPresent(t -> sb.append("text: ").append(t));
|
||||
p.functionCall().ifPresent(f -> sb.append("call ").append(f.name().orElse("?"))
|
||||
.append(f.args().orElse(java.util.Map.of())));
|
||||
p.functionResponse().ifPresent(f -> sb.append("result ").append(f.name().orElse("?"))
|
||||
.append(f.response().orElse(java.util.Map.of())));
|
||||
}
|
||||
});
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoAgentsRunInOrderAndShareSessionState() throws Exception {
|
||||
var t = new Transcript("01-two-agents-in-order.txt");
|
||||
var llm = new ScriptedLlm();
|
||||
BaseAgent root = SupportPipeline.build(llm);
|
||||
var runner = new InMemoryRunner(root, APP);
|
||||
Session session = runner.sessionService().createSession(APP, "user-1").blockingGet();
|
||||
|
||||
List<Event> events = runner.runAsync("user-1", session.id(),
|
||||
Content.fromParts(Part.fromText("Where is my order A-1001?"))).toList().blockingGet();
|
||||
|
||||
for (Event e : events) {
|
||||
t.line(String.format("%-11s %s", e.author(), describe(e)));
|
||||
}
|
||||
|
||||
Session after = runner.sessionService().getSession(APP, "user-1", session.id(), java.util.Optional.empty()).blockingGet();
|
||||
t.line("session state keys: " + after.state().keySet());
|
||||
t.line("state[findings] = " + after.state().get("findings"));
|
||||
t.write();
|
||||
|
||||
assertEquals("researcher", events.get(0).author());
|
||||
assertEquals("writer", events.get(events.size() - 1).author());
|
||||
assertTrue(after.state().containsKey("findings"));
|
||||
assertTrue(describe(events.get(events.size() - 1)).contains("BlueDart"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theWriterModelReceivesTheResearcherOutputInItsInstruction() throws Exception {
|
||||
var t = new Transcript("02-state-injected-into-instruction.txt");
|
||||
var llm = new ScriptedLlm();
|
||||
var runner = new InMemoryRunner(SupportPipeline.build(llm), APP);
|
||||
Session session = runner.sessionService().createSession(APP, "user-2").blockingGet();
|
||||
runner.runAsync("user-2", session.id(), Content.fromParts(Part.fromText("Status of A-1001?")))
|
||||
.toList().blockingGet();
|
||||
|
||||
t.line("model calls made: " + llm.seen.size());
|
||||
for (int i = 0; i < llm.seen.size(); i++) {
|
||||
var r = llm.seen.get(i);
|
||||
t.line("call " + (i + 1) + ": tools offered = " + r.tools().keySet());
|
||||
}
|
||||
String writerInstruction = llm.seen.get(llm.seen.size() - 1).getFirstSystemInstruction().orElse("");
|
||||
t.line("writer system instruction contains the findings: " + writerInstruction.contains("BlueDart"));
|
||||
t.write();
|
||||
|
||||
assertEquals(3, llm.seen.size()); // researcher x2 (call, then answer), writer x1
|
||||
assertTrue(writerInstruction.contains("BlueDart"));
|
||||
assertFalse(writerInstruction.contains("{findings}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionsAreSeparatePerUser() throws Exception {
|
||||
var t = new Transcript("03-sessions-per-user.txt");
|
||||
var runner = new InMemoryRunner(SupportPipeline.build(new ScriptedLlm()), APP);
|
||||
Session s1 = runner.sessionService().createSession(APP, "user-1").blockingGet();
|
||||
Session s2 = runner.sessionService().createSession(APP, "user-2").blockingGet();
|
||||
runner.runAsync("user-1", s1.id(), Content.fromParts(Part.fromText("order A-1001"))).toList().blockingGet();
|
||||
|
||||
var a = runner.sessionService().getSession(APP, "user-1", s1.id(), java.util.Optional.empty()).blockingGet();
|
||||
var b = runner.sessionService().getSession(APP, "user-2", s2.id(), java.util.Optional.empty()).blockingGet();
|
||||
t.line("user-1 events: " + a.events().size() + ", keys: " + a.state().keySet());
|
||||
t.line("user-2 events: " + b.events().size() + ", keys: " + b.state().keySet());
|
||||
t.write();
|
||||
|
||||
assertTrue(a.events().size() > 0);
|
||||
assertEquals(0, b.events().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.adk;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */
|
||||
final class Transcript {
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final String file;
|
||||
|
||||
Transcript(String file) { this.file = file; }
|
||||
|
||||
void line(String s) { lines.add(s); }
|
||||
|
||||
void write() throws IOException {
|
||||
Path dir = Path.of("output");
|
||||
Files.createDirectories(dir);
|
||||
Files.write(dir.resolve(file), lines);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
chunks in diff: 8
|
||||
model calls: 3
|
||||
chunks skipped by the cap: 5
|
||||
approximate characters sent: 993
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user