From 7c20ea1370fc2793c81265de7bcb58a68b228bc9 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Fri, 6 Nov 2026 21:43:16 +0530 Subject: [PATCH] New posts --- README.md | 1 + agentic-patterns/.gitignore | 1 + agentic-patterns/README.md | 25 +++ agentic-patterns/output/01-prompt-chain.txt | 6 + .../output/02-chain-gate-failure.txt | 4 + agentic-patterns/output/03-routing.txt | 13 ++ .../output/04-routing-fallback.txt | 5 + agentic-patterns/output/05-sectioning.txt | 8 + .../output/06-sectioning-branch-failure.txt | 7 + agentic-patterns/output/07-voting.txt | 5 + .../output/08-orchestrator-workers.txt | 9 + .../output/09-orchestrator-malformed-plan.txt | 18 ++ .../output/10-evaluator-converges.txt | 10 + .../output/11-evaluator-max-rounds.txt | 10 + agentic-patterns/output/12-cost-latency.txt | 10 + .../output/13-planner-prompt-as-sent.txt | 33 ++++ .../14-structured-task-scope-preview.txt | 19 ++ agentic-patterns/pom.xml | 55 ++++++ agentic-patterns/preview/ScopeProbe.java | 13 ++ .../scripts/probe-structured-task-scope.sh | 20 ++ agentic-patterns/scripts/run-all.sh | 7 + .../ankurm/agentic/EvaluatorOptimizer.java | 45 +++++ .../java/com/ankurm/agentic/Orchestrator.java | 73 +++++++ .../java/com/ankurm/agentic/Parallelizer.java | 75 ++++++++ .../java/com/ankurm/agentic/PromptChain.java | 46 +++++ .../main/java/com/ankurm/agentic/Router.java | 45 +++++ .../java/com/ankurm/agentic/CostTest.java | 73 +++++++ .../agentic/EvaluatorOptimizerTest.java | 57 ++++++ .../com/ankurm/agentic/OrchestratorTest.java | 88 +++++++++ .../ankurm/agentic/ParallelizationTest.java | 75 ++++++++ .../com/ankurm/agentic/PromptChainTest.java | 49 +++++ .../java/com/ankurm/agentic/RouterTest.java | 54 ++++++ .../java/com/ankurm/agentic/Scenarios.java | 74 +++++++ .../com/ankurm/agentic/ScriptedChatModel.java | 181 ++++++++++++++++++ .../java/com/ankurm/agentic/Transcript.java | 49 +++++ pom.xml | 1 + 36 files changed, 1264 insertions(+) create mode 100644 agentic-patterns/.gitignore create mode 100644 agentic-patterns/README.md create mode 100644 agentic-patterns/output/01-prompt-chain.txt create mode 100644 agentic-patterns/output/02-chain-gate-failure.txt create mode 100644 agentic-patterns/output/03-routing.txt create mode 100644 agentic-patterns/output/04-routing-fallback.txt create mode 100644 agentic-patterns/output/05-sectioning.txt create mode 100644 agentic-patterns/output/06-sectioning-branch-failure.txt create mode 100644 agentic-patterns/output/07-voting.txt create mode 100644 agentic-patterns/output/08-orchestrator-workers.txt create mode 100644 agentic-patterns/output/09-orchestrator-malformed-plan.txt create mode 100644 agentic-patterns/output/10-evaluator-converges.txt create mode 100644 agentic-patterns/output/11-evaluator-max-rounds.txt create mode 100644 agentic-patterns/output/12-cost-latency.txt create mode 100644 agentic-patterns/output/13-planner-prompt-as-sent.txt create mode 100644 agentic-patterns/output/14-structured-task-scope-preview.txt create mode 100644 agentic-patterns/pom.xml create mode 100644 agentic-patterns/preview/ScopeProbe.java create mode 100644 agentic-patterns/scripts/probe-structured-task-scope.sh create mode 100644 agentic-patterns/scripts/run-all.sh create mode 100644 agentic-patterns/src/main/java/com/ankurm/agentic/EvaluatorOptimizer.java create mode 100644 agentic-patterns/src/main/java/com/ankurm/agentic/Orchestrator.java create mode 100644 agentic-patterns/src/main/java/com/ankurm/agentic/Parallelizer.java create mode 100644 agentic-patterns/src/main/java/com/ankurm/agentic/PromptChain.java create mode 100644 agentic-patterns/src/main/java/com/ankurm/agentic/Router.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/CostTest.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/EvaluatorOptimizerTest.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/OrchestratorTest.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/ParallelizationTest.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/PromptChainTest.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/RouterTest.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/Scenarios.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/ScriptedChatModel.java create mode 100644 agentic-patterns/src/test/java/com/ankurm/agentic/Transcript.java diff --git a/README.md b/README.md index 249b463..492c89e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ facts, so the build fails when a claim stops being true. | `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 | +| `agentic-patterns` | Agentic Patterns in Spring AI: Routing, Orchestrator-Workers, Evaluator-Optimizer and Parallelization | Anthropic's five workflows as plain `ChatClient` code, tested offline with a scripted `ChatModel` that records every prompt, with failure modes and a calls-and-stages cost table | ## Versions (verified on Maven Central, 2026-10-11) diff --git a/agentic-patterns/.gitignore b/agentic-patterns/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/agentic-patterns/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/agentic-patterns/README.md b/agentic-patterns/README.md new file mode 100644 index 0000000..b0a36d6 --- /dev/null +++ b/agentic-patterns/README.md @@ -0,0 +1,25 @@ +# agentic-patterns + +Companion code for *Agentic Patterns in Spring AI: Routing, Orchestrator-Workers, Evaluator-Optimizer and Parallelization* +on [ankurm.com](https://ankurm.com). The five workflows from Anthropic's +[Building effective agents](https://www.anthropic.com/engineering/building-effective-agents) as plain `ChatClient` code, +tested against a scripted `ChatModel` that records every prompt. No API key, no network call to a model. + +```bash +mvn test -pl agentic-patterns # from the repo root; rewrites output/01..13 +bash agentic-patterns/scripts/run-all.sh # the same, plus output/14 +``` + +Versions: Spring AI `spring-ai-client-chat` 2.0.2, Spring Boot BOM 4.1.2, JDK 25, JUnit 6.1.3. The parent pom still pins +2.0.1 and 4.1.1 for the other modules; this module overrides both in its own ``. + +| File | Role | +|---|---| +| `PromptChain`, `Router`, `Parallelizer`, `Orchestrator`, `EvaluatorOptimizer` | One class per pattern, under `src/main/java/com/ankurm/agentic/` | +| `ScriptedChatModel` (test) | Rule-based fake `ChatModel`: scripted replies, recorded prompts, logical start/end ticks | +| `Scenarios`, `Transcript` (test) | The shared scripted conversations; writes `output/NN-*.txt` | +| `*Test` | One class per pattern, plus `CostTest` for the call-count table | +| `preview/ScopeProbe.java`, `scripts/probe-structured-task-scope.sh` | Standalone probe of `StructuredTaskScope` on JDK 25 (preview), writes `output/14` | +| `output/` | Captured transcripts quoted in the post | + +Not covered: a real model's behaviour (the scripted replies prove the control flow, not the quality), streaming, and tool calling inside a step. diff --git a/agentic-patterns/output/01-prompt-chain.txt b/agentic-patterns/output/01-prompt-chain.txt new file mode 100644 index 0000000..f39fa1f --- /dev/null +++ b/agentic-patterns/output/01-prompt-chain.txt @@ -0,0 +1,6 @@ +# prompt chain: git log -> bullets -> gate -> summary +call 1 | system: You turn a git log into release-note bullets. Reply with... | user: abc123 add --json flag to the export command [+2 lines] +call 2 | system: You write release notes. Turn the bullets into a two-sen... | user: - Added a --json flag to the export command [+1 lines] +calls: 2 +bullets: - Added a --json flag to the export command / - Fixed a crash on empty input files +summary: Export can now write JSON with --json. Empty input files no longer crash it. diff --git a/agentic-patterns/output/02-chain-gate-failure.txt b/agentic-patterns/output/02-chain-gate-failure.txt new file mode 100644 index 0000000..b4b23f6 --- /dev/null +++ b/agentic-patterns/output/02-chain-gate-failure.txt @@ -0,0 +1,4 @@ +# prompt chain: step 1 breaks the bullet format +call 1 | system: You turn a git log into release-note bullets. Reply with... | user: abc123 add --json flag to the export command [+2 lines] +gate: step 1 line is not a bullet: Sure! Here are the changes: +calls: 1 (the summary call was not made) diff --git a/agentic-patterns/output/03-routing.txt b/agentic-patterns/output/03-routing.txt new file mode 100644 index 0000000..2ee48e5 --- /dev/null +++ b/agentic-patterns/output/03-routing.txt @@ -0,0 +1,13 @@ +# routing: one classification call, then one specialised call +message: I was charged twice this month +call 1 | system: Classify the support message. Reply with exactly one wor... | user: I was charged twice this month +call 2 | system: You are a billing specialist. Answer in two sentences an... | user: I was charged twice this month +category: BILLING, fell back: false +message: The connector returns 401 since Monday +call 1 | system: Classify the support message. Reply with exactly one wor... | user: The connector returns 401 since Monday +call 2 | system: You are a support engineer. Answer in two sentences and ... | user: The connector returns 401 since Monday +category: TECHNICAL, fell back: false +message: Do you have an office in Pune? +call 1 | system: Classify the support message. Reply with exactly one wor... | user: Do you have an office in Pune? +call 2 | system: You are a friendly support agent. Answer in two sentence... | user: Do you have an office in Pune? +category: GENERAL, fell back: false diff --git a/agentic-patterns/output/04-routing-fallback.txt b/agentic-patterns/output/04-routing-fallback.txt new file mode 100644 index 0000000..08876c4 --- /dev/null +++ b/agentic-patterns/output/04-routing-fallback.txt @@ -0,0 +1,5 @@ +# routing: what the classifier says versus where the message goes +classifier said [Technical.] -> TECHNICAL, fell back: false +classifier said [I think this is probably a billing question] -> GENERAL, fell back: true +classifier said [REFUNDS] -> GENERAL, fell back: true +classifier said [] -> GENERAL, fell back: true diff --git a/agentic-patterns/output/05-sectioning.txt b/agentic-patterns/output/05-sectioning.txt new file mode 100644 index 0000000..1a36a0f --- /dev/null +++ b/agentic-patterns/output/05-sectioning.txt @@ -0,0 +1,8 @@ +# sectioning: three prompts, same input, merged in plain Java +call | system: You review Java code for performance only. Reply with on... | user: String q = "select * from users where id=" + id; +call | system: You review Java code for readability only. Reply with on... | user: String q = "select * from users where id=" + id; +call | system: You review Java code for security only. Reply with one s... | user: String q = "select * from users where id=" + id; +calls: 3, most calls in flight at once: 3 +security: SQL is built by string concatenation, which allows injection. +performance: select * pulls every column when one row is needed. +readability: The query string should be a named constant. diff --git a/agentic-patterns/output/06-sectioning-branch-failure.txt b/agentic-patterns/output/06-sectioning-branch-failure.txt new file mode 100644 index 0000000..9553352 --- /dev/null +++ b/agentic-patterns/output/06-sectioning-branch-failure.txt @@ -0,0 +1,7 @@ +# sectioning: the performance branch throws +call | system: You review Java code for performance only. Reply with on... | user: String q = "select * from users where id=" + id; +call | system: You review Java code for readability only. Reply with on... | user: String q = "select * from users where id=" + id; +call | system: You review Java code for security only. Reply with one s... | user: String q = "select * from users where id=" + id; +security SQL is built by string concatenation, which allows injection. +performance [FAILED] unavailable: 429 rate limited +readability The query string should be a named constant. diff --git a/agentic-patterns/output/07-voting.txt b/agentic-patterns/output/07-voting.txt new file mode 100644 index 0000000..675f55d --- /dev/null +++ b/agentic-patterns/output/07-voting.txt @@ -0,0 +1,5 @@ +# voting: the same prompt three times, majority wins, ties are UNSAFE +ballots SAFE | UNSAFE | UNSAFE -> UNSAFE (safe=1, unsafe=2, spoiled=0) +ballots SAFE | SAFE | Probably fine! -> SAFE (safe=2, unsafe=0, spoiled=1) +ballots SAFE | UNSAFE | Hmm -> UNSAFE (safe=1, unsafe=1, spoiled=1) +calls per vote: 3, run at once diff --git a/agentic-patterns/output/08-orchestrator-workers.txt b/agentic-patterns/output/08-orchestrator-workers.txt new file mode 100644 index 0000000..73d2547 --- /dev/null +++ b/agentic-patterns/output/08-orchestrator-workers.txt @@ -0,0 +1,9 @@ +# orchestrator-workers: plan at run time, one worker per step, one synthesis +call 1 | system: You are a planner. Break the task into 1 to 5 independen... | user: Add a --json output flag to the export CLI [+29 lines] +call 2 | system: You carry out one step of a larger task. Reply with the ... | user: Task: Add a --json output flag to the export CLI [+2 lines] +call 3 | system: You carry out one step of a larger task. Reply with the ... | user: Task: Add a --json output flag to the export CLI [+2 lines] +call 4 | system: You carry out one step of a larger task. Reply with the ... | user: Task: Add a --json output flag to the export CLI [+2 lines] +call 5 | system: Combine the step results into one final answer for the t... | user: Task: Add a --json output flag to the export CLI [+9 lines] +plan steps: [Parse the flag, Serialise rows, Document it] +calls: 5 (1 plan + 3 workers + 1 synthesis) +answer: Three changes: ExportArgs gets --json, Exporter writes JSON, docs mention it. diff --git a/agentic-patterns/output/09-orchestrator-malformed-plan.txt b/agentic-patterns/output/09-orchestrator-malformed-plan.txt new file mode 100644 index 0000000..3e571ea --- /dev/null +++ b/agentic-patterns/output/09-orchestrator-malformed-plan.txt @@ -0,0 +1,18 @@ +# (a) prose first, valid JSON on the retry +call 1 | system: You are a planner. Break the task into 1 to 5 independen... | user: Add a --json output flag to the export CLI [+29 lines] +call 2 | system: You are a planner. Break the task into 1 to 5 independen... | user: Add a --json output flag to the export CLI [+31 lines] +call 3 | system: You carry out one step of a larger task. Reply with the ... | user: Task: Add a --json output flag to the export CLI [+2 lines] +call 4 | system: You carry out one step of a larger task. Reply with the ... | user: Task: Add a --json output flag to the export CLI [+2 lines] +call 5 | system: You carry out one step of a larger task. Reply with the ... | user: Task: Add a --json output flag to the export CLI [+2 lines] +call 6 | system: Combine the step results into one final answer for the t... | user: Task: Add a --json output flag to the export CLI [+9 lines] +plan errors: [StreamReadException], attempts: 2, calls: 6 +# (b) seven steps twice: over the limit of 5 +call 1 | system: You are a planner. Break the task into 1 to 5 independen... | user: Add a --json output flag to the export CLI [+29 lines] +call 2 | system: You are a planner. Break the task into 1 to 5 independen... | user: Add a --json output flag to the export CLI [+31 lines] +call 3 | system: (none) | user: Add a --json output flag to the export CLI +plan errors: [IllegalStateException, IllegalStateException], fell back: true, answer: Just do it: one direct answer. +# (c) prose twice +call 1 | system: You are a planner. Break the task into 1 to 5 independen... | user: Add a --json output flag to the export CLI [+29 lines] +call 2 | system: You are a planner. Break the task into 1 to 5 independen... | user: Add a --json output flag to the export CLI [+31 lines] +call 3 | system: (none) | user: Add a --json output flag to the export CLI +plan errors: [StreamReadException, StreamReadException], fell back: true diff --git a/agentic-patterns/output/10-evaluator-converges.txt b/agentic-patterns/output/10-evaluator-converges.txt new file mode 100644 index 0000000..40dff6f --- /dev/null +++ b/agentic-patterns/output/10-evaluator-converges.txt @@ -0,0 +1,10 @@ +# evaluator-optimizer: three rounds, PASS on the third +call 1 | system: Write a git commit message: imperative mood, at most 50 ... | user: Add a --json output flag to the export CLI +call 2 | system: You judge a commit message against the rules: imperative... | user: Added a --json flag to the export command +call 3 | system: Write a git commit message: imperative mood, at most 50 ... | user: Add a --json output flag to the export CLI [+3 lines] +call 4 | system: You judge a commit message against the rules: imperative... | user: Add a --json flag to the export command. +call 5 | system: Write a git commit message: imperative mood, at most 50 ... | user: Add a --json output flag to the export CLI [+3 lines] +call 6 | system: You judge a commit message against the rules: imperative... | user: Add --json output flag +converged: true, rounds: 3, calls: 6 +best: Add --json output flag +feedback: [Use the imperative mood., Remove the trailing period.] diff --git a/agentic-patterns/output/11-evaluator-max-rounds.txt b/agentic-patterns/output/11-evaluator-max-rounds.txt new file mode 100644 index 0000000..68f8845 --- /dev/null +++ b/agentic-patterns/output/11-evaluator-max-rounds.txt @@ -0,0 +1,10 @@ +# evaluator-optimizer: the judge never says PASS, maxRounds = 3 +call 1 | system: Write a git commit message: imperative mood, at most 50 ... | user: Add a --json output flag to the export CLI +call 2 | system: You judge a commit message against the rules: imperative... | user: Add a --json output flag to the export command of the CL... +call 3 | system: Write a git commit message: imperative mood, at most 50 ... | user: Add a --json output flag to the export CLI [+3 lines] +call 4 | system: You judge a commit message against the rules: imperative... | user: Add a --json output flag to the export command of CLI +call 5 | system: Write a git commit message: imperative mood, at most 50 ... | user: Add a --json output flag to the export CLI [+3 lines] +call 6 | system: You judge a commit message against the rules: imperative... | user: Add a --json output flag to the export command, CLI +converged: false, rounds: 3, calls: 6 +best so far (returned, flagged): Add a --json output flag to the export command, CLI (51 chars) +feedback: [Longer than 50 characters., Looks good to me!, Still longer than 50 characters.] diff --git a/agentic-patterns/output/12-cost-latency.txt b/agentic-patterns/output/12-cost-latency.txt new file mode 100644 index 0000000..0a72004 --- /dev/null +++ b/agentic-patterns/output/12-cost-latency.txt @@ -0,0 +1,10 @@ +# calls, sequential stages and prompt characters per pattern (scripted model, 100 ms fake latency per call) +pattern calls stages in flight prompt chars +single call (baseline) 1 1 1 130 +prompt chain 2 2 1 430 +routing 2 2 1 251 +sectioning x3 3 1 3 342 +voting x3 3 1 3 261 +orchestrator, 3 steps 5 5 1 1929 +evaluator, passes in round 3 6 6 1 1293 +evaluator, hits the 3-round cap 6 6 1 1372 diff --git a/agentic-patterns/output/13-planner-prompt-as-sent.txt b/agentic-patterns/output/13-planner-prompt-as-sent.txt new file mode 100644 index 0000000..885ee00 --- /dev/null +++ b/agentic-patterns/output/13-planner-prompt-as-sent.txt @@ -0,0 +1,33 @@ +# system message the planner call carried +You are a planner. Break the task into 1 to 5 independent steps. Each step has a title and an instruction. +# user message the planner call carried: the task, then what entity(Plan.class) appended +Add a --json output flag to the export CLI +Your response should be in JSON format. +Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. +Do not include markdown code blocks in your response. +Remove the ```json markdown from the output. +Here is the JSON Schema instance your output must adhere to: +```{ + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "steps" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "instruction" : { + "type" : "string" + }, + "title" : { + "type" : "string" + } + }, + "required" : [ "instruction", "title" ], + "additionalProperties" : false + } + } + }, + "required" : [ "steps" ], + "additionalProperties" : false +}``` diff --git a/agentic-patterns/output/14-structured-task-scope-preview.txt b/agentic-patterns/output/14-structured-task-scope-preview.txt new file mode 100644 index 0000000..4d5ec27 --- /dev/null +++ b/agentic-patterns/output/14-structured-task-scope-preview.txt @@ -0,0 +1,19 @@ +# JDK: openjdk version "25.0.5" 2026-10-20 LTS +$ javac --release 25 ScopeProbe.java +ScopeProbe.java:1: error: StructuredTaskScope is a preview API and is disabled by default. +import java.util.concurrent.StructuredTaskScope; + ^ + (use --enable-preview to enable preview APIs) +ScopeProbe.java:6: error: StructuredTaskScope is a preview API and is disabled by default. + try (var scope = StructuredTaskScope.open()) { + ^ + (use --enable-preview to enable preview APIs) +2 errors +$ javac --release 25 --enable-preview ScopeProbe.java +Note: ScopeProbe.java uses preview features of Java SE 25. +Note: Recompile with -Xlint:preview for details. +$ java -cp preview-classes ScopeProbe +Error: LinkageError occurred while loading main class ScopeProbe + java.lang.UnsupportedClassVersionError: Preview features are not enabled for ScopeProbe (class file version 69.65535). Try running with '--enable-preview' +$ java --enable-preview -cp preview-classes ScopeProbe +security: ok | performance: ok diff --git a/agentic-patterns/pom.xml b/agentic-patterns/pom.xml new file mode 100644 index 0000000..8778556 --- /dev/null +++ b/agentic-patterns/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + com.ankurm.agents + java-ai-agents + 1.0.0 + + + agentic-patterns + Agentic workflow patterns with Spring AI ChatClient + + + + 2.0.2 + 4.1.2 + + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + org.springframework.ai + spring-ai-client-chat + ${spring-ai.version} + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/agentic-patterns/preview/ScopeProbe.java b/agentic-patterns/preview/ScopeProbe.java new file mode 100644 index 0000000..1ebc114 --- /dev/null +++ b/agentic-patterns/preview/ScopeProbe.java @@ -0,0 +1,13 @@ +import java.util.concurrent.StructuredTaskScope; + +/** Standalone probe, not part of the Maven build: is StructuredTaskScope usable on this JDK without flags? */ +public class ScopeProbe { + public static void main(String[] args) throws Exception { + try (var scope = StructuredTaskScope.open()) { + var a = scope.fork(() -> "security: ok"); + var b = scope.fork(() -> "performance: ok"); + scope.join(); + System.out.println(a.get() + " | " + b.get()); + } + } +} diff --git a/agentic-patterns/scripts/probe-structured-task-scope.sh b/agentic-patterns/scripts/probe-structured-task-scope.sh new file mode 100644 index 0000000..fa6837e --- /dev/null +++ b/agentic-patterns/scripts/probe-structured-task-scope.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Writes output/14-structured-task-scope-preview.txt: what javac and java say about StructuredTaskScope on this JDK. +set -u +cd "$(dirname "$0")/.." +OUT=output/14-structured-task-scope-preview.txt +WORK=$(mktemp -d) +mkdir -p output +{ + echo "# JDK: $(java -version 2>&1 | grep -v JAVA_TOOL | head -1)" + echo '$ javac --release 25 ScopeProbe.java' + javac --release 25 -d "$WORK/plain" preview/ScopeProbe.java 2>&1 | grep -v JAVA_TOOL | sed "s#preview/##" + echo '$ javac --release 25 --enable-preview ScopeProbe.java' + javac --release 25 --enable-preview -d "$WORK/preview" preview/ScopeProbe.java 2>&1 | grep -v JAVA_TOOL | sed "s#preview/##" + echo '$ java -cp preview-classes ScopeProbe' + java -cp "$WORK/preview" ScopeProbe 2>&1 | grep -v JAVA_TOOL || true + echo "$ java --enable-preview -cp preview-classes ScopeProbe" + java --enable-preview -cp "$WORK/preview" ScopeProbe 2>&1 | grep -v JAVA_TOOL +} > "$OUT" +rm -rf "$WORK" +cat "$OUT" diff --git a/agentic-patterns/scripts/run-all.sh b/agentic-patterns/scripts/run-all.sh new file mode 100644 index 0000000..8a59ae9 --- /dev/null +++ b/agentic-patterns/scripts/run-all.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Regenerates every file in output/. Needs JDK 25 and Maven on the PATH; no API key, no network except Maven Central. +set -eu +cd "$(dirname "$0")/.." +(cd .. && mvn -B -q test -pl agentic-patterns) # tests write output/01..13 +bash scripts/probe-structured-task-scope.sh >/dev/null # writes output/14 +ls -1 output diff --git a/agentic-patterns/src/main/java/com/ankurm/agentic/EvaluatorOptimizer.java b/agentic-patterns/src/main/java/com/ankurm/agentic/EvaluatorOptimizer.java new file mode 100644 index 0000000..9cb9894 --- /dev/null +++ b/agentic-patterns/src/main/java/com/ankurm/agentic/EvaluatorOptimizer.java @@ -0,0 +1,45 @@ +package com.ankurm.agentic; + +import org.springframework.ai.chat.client.ChatClient; + +import java.util.ArrayList; +import java.util.List; + +/** + * Pattern 5, evaluator-optimizer: generate, judge, revise with the judge's feedback, and stop on PASS or after + * maxRounds. The cap is the whole point: without it a judge that never says PASS spends money forever. + */ +public final class EvaluatorOptimizer { + + public static final String GENERATOR = "Write a git commit message: imperative mood, at most 50 characters, " + + "no trailing period. Reply with the message only."; + public static final String EVALUATOR = "You judge a commit message against the rules: imperative mood, " + + "at most 50 characters, no trailing period. Reply PASS, or FAIL: followed by one sentence of feedback."; + + public record Result(String best, boolean converged, int rounds, List drafts, List feedback) {} + + private final ChatClient chat; + + public EvaluatorOptimizer(ChatClient chat) { + this.chat = chat; + } + + public Result run(String task, int maxRounds) { + List drafts = new ArrayList<>(); + List feedback = new ArrayList<>(); + String draft = null; + for (int round = 1; round <= maxRounds; round++) { + String user = draft == null ? task + : task + "\nPrevious draft: " + draft + "\nFeedback: " + feedback.getLast() + "\nRevise the draft."; + draft = chat.prompt().system(GENERATOR).user(user).call().content().trim(); + drafts.add(draft); + String verdict = chat.prompt().system(EVALUATOR).user(draft).call().content().trim(); + if (verdict.equalsIgnoreCase("PASS")) { + return new Result(draft, true, round, drafts, feedback); + } + // Anything that is not exactly PASS counts as a failure, so a rambling judge cannot pass a draft by accident. + feedback.add(verdict.regionMatches(true, 0, "FAIL:", 0, 5) ? verdict.substring(5).trim() : verdict); + } + return new Result(draft, false, maxRounds, drafts, feedback); // best effort, flagged as not converged + } +} diff --git a/agentic-patterns/src/main/java/com/ankurm/agentic/Orchestrator.java b/agentic-patterns/src/main/java/com/ankurm/agentic/Orchestrator.java new file mode 100644 index 0000000..6634f30 --- /dev/null +++ b/agentic-patterns/src/main/java/com/ankurm/agentic/Orchestrator.java @@ -0,0 +1,73 @@ +package com.ankurm.agentic; + +import org.springframework.ai.chat.client.ChatClient; + +import java.util.ArrayList; +import java.util.List; + +/** + * Pattern 4, orchestrator-workers: one call decides the steps at run time (as typed JSON), one call per step does the work, + * one call synthesises. The plan is validated; if it cannot be parsed twice, the code falls back to a single direct call. + */ +public final class Orchestrator { + + public static final int MAX_STEPS = 5; + + public static final String PLANNER = "You are a planner. Break the task into 1 to " + MAX_STEPS + + " independent steps. Each step has a title and an instruction."; + public static final String WORKER = "You carry out one step of a larger task. Reply with the result of that step only."; + public static final String SYNTHESISER = "Combine the step results into one final answer for the task."; + + public record Step(String title, String instruction) {} + + public record Plan(List steps) {} + + public record Result(Plan plan, int planAttempts, List stepResults, String answer, boolean fellBack, + List planErrors) {} + + private final ChatClient chat; + + public Orchestrator(ChatClient chat) { + this.chat = chat; + } + + public Result run(String task) { + List errors = new ArrayList<>(); + String userPrompt = task; + for (int attempt = 1; attempt <= 2; attempt++) { + try { + Plan plan = chat.prompt().system(PLANNER).user(userPrompt).call().entity(Plan.class); + validate(plan); + return execute(task, plan, attempt, errors); + } catch (RuntimeException e) { + errors.add(e.getClass().getSimpleName()); + userPrompt = task + "\n\nYour previous reply could not be used (" + e.getClass().getSimpleName() + + "). Reply with valid JSON only."; + } + } + // Two bad plans: stop orchestrating and answer directly. Worse, but it is an answer. + String direct = chat.prompt().user(task).call().content(); + return new Result(null, 2, List.of(), direct, true, errors); + } + + static void validate(Plan plan) { + if (plan == null || plan.steps() == null || plan.steps().isEmpty() || plan.steps().size() > MAX_STEPS) { + throw new IllegalStateException("plan must have 1.." + MAX_STEPS + " steps"); + } + } + + private Result execute(String task, Plan plan, int attempts, List errors) { + List results = new ArrayList<>(); + for (Step step : plan.steps()) { + results.add(chat.prompt().system(WORKER) + .user("Task: " + task + "\nStep: " + step.title() + "\nInstruction: " + step.instruction()) + .call().content()); + } + StringBuilder joined = new StringBuilder("Task: " + task + "\n"); + for (int i = 0; i < results.size(); i++) { + joined.append("\n## ").append(plan.steps().get(i).title()).append('\n').append(results.get(i)).append('\n'); + } + String answer = chat.prompt().system(SYNTHESISER).user(joined.toString()).call().content(); + return new Result(plan, attempts, results, answer, false, errors); + } +} diff --git a/agentic-patterns/src/main/java/com/ankurm/agentic/Parallelizer.java b/agentic-patterns/src/main/java/com/ankurm/agentic/Parallelizer.java new file mode 100644 index 0000000..b7860f7 --- /dev/null +++ b/agentic-patterns/src/main/java/com/ankurm/agentic/Parallelizer.java @@ -0,0 +1,75 @@ +package com.ankurm.agentic; + +import org.springframework.ai.chat.client.ChatClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; + +/** + * Pattern 3, parallelization, in both variants from the Anthropic article. + * Sectioning: different prompts on the same input, run at once, merged in plain Java. + * Voting: the same prompt several times, majority wins. Both run on virtual threads through + * CompletableFuture, which is stable on JDK 25 (StructuredTaskScope is still a preview API there). + */ +public final class Parallelizer { + + public static final List ASPECTS = List.of("security", "performance", "readability"); + public static final String VOTE = "Is this text safe to publish? Reply with exactly SAFE or UNSAFE."; + + public record Section(String aspect, String text, boolean failed) {} + + public record Verdict(String verdict, int safe, int unsafe, int spoiled) {} + + private final ChatClient chat; + + public Parallelizer(ChatClient chat) { + this.chat = chat; + } + + public static String aspectPrompt(String aspect) { + return "You review Java code for " + aspect + " only. Reply with one sentence."; + } + + /** Sectioning. A branch that throws becomes a marked, failed section instead of sinking the whole review. */ + public List
sectioning(String code) { + try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = ASPECTS.stream() + .map(aspect -> CompletableFuture.supplyAsync(() -> { + try { + String text = chat.prompt().system(aspectPrompt(aspect)).user(code).call().content(); + return new Section(aspect, text, false); + } catch (RuntimeException e) { + return new Section(aspect, "unavailable: " + e.getMessage(), true); + } + }, pool)) + .toList(); + return futures.stream().map(CompletableFuture::join).toList(); // ASPECTS order, not finish order + } + } + + /** Voting. A tie is UNSAFE: when the voters disagree evenly, the cautious answer wins. */ + public Verdict vote(String text, int voters) { + try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { + List> ballots = IntStream.range(0, voters) + .mapToObj(i -> CompletableFuture.supplyAsync( + () -> chat.prompt().system(VOTE).user(text).call().content(), pool)) + .toList(); + int safe = 0, unsafe = 0, spoiled = 0; + List answers = new ArrayList<>(); + for (var ballot : ballots) { + answers.add(ballot.join().trim()); + } + for (String a : answers) { + switch (a) { + case "SAFE" -> safe++; + case "UNSAFE" -> unsafe++; + default -> spoiled++; // anything but the two words is not a vote + } + } + return new Verdict(safe > unsafe ? "SAFE" : "UNSAFE", safe, unsafe, spoiled); + } + } +} diff --git a/agentic-patterns/src/main/java/com/ankurm/agentic/PromptChain.java b/agentic-patterns/src/main/java/com/ankurm/agentic/PromptChain.java new file mode 100644 index 0000000..ac4efdf --- /dev/null +++ b/agentic-patterns/src/main/java/com/ankurm/agentic/PromptChain.java @@ -0,0 +1,46 @@ +package com.ankurm.agentic; + +import org.springframework.ai.chat.client.ChatClient; + +/** + * Pattern 1, prompt chaining: a fixed sequence of calls, each one reading the previous answer, + * with a plain-Java gate between them. If the gate fails the chain stops and the later calls are never paid for. + */ +public final class PromptChain { + + public static final String EXTRACT = "You turn a git log into release-note bullets. " + + "Reply with one bullet per user-visible change, each line starting with \"- \". Nothing else."; + public static final String WRITE = "You write release notes. Turn the bullets into a two-sentence summary for end users."; + + public record Result(String bullets, String summary) {} + + public static final class GateFailedException extends RuntimeException { + public GateFailedException(String message) { + super(message); + } + } + + private final ChatClient chat; + + public PromptChain(ChatClient chat) { + this.chat = chat; + } + + public Result run(String gitLog) { + String bullets = chat.prompt().system(EXTRACT).user(gitLog).call().content(); + gate(bullets); // programmatic check, no model involved + String summary = chat.prompt().system(WRITE).user(bullets).call().content(); + return new Result(bullets, summary); + } + + static void gate(String bullets) { + if (bullets == null || bullets.isBlank()) { + throw new GateFailedException("step 1 returned nothing"); + } + for (String line : bullets.split("\n")) { + if (!line.isBlank() && !line.startsWith("- ")) { + throw new GateFailedException("step 1 line is not a bullet: " + line); + } + } + } +} diff --git a/agentic-patterns/src/main/java/com/ankurm/agentic/Router.java b/agentic-patterns/src/main/java/com/ankurm/agentic/Router.java new file mode 100644 index 0000000..11ad730 --- /dev/null +++ b/agentic-patterns/src/main/java/com/ankurm/agentic/Router.java @@ -0,0 +1,45 @@ +package com.ankurm.agentic; + +import org.springframework.ai.chat.client.ChatClient; + +import java.util.Locale; +import java.util.Map; + +/** + * Pattern 2, routing: one cheap classification call picks which specialised prompt answers the message. + * A label that is not one of the known categories falls back to GENERAL instead of guessing. + */ +public final class Router { + + public enum Category { BILLING, TECHNICAL, GENERAL } + + public static final String CLASSIFY = + "Classify the support message. Reply with exactly one word: BILLING, TECHNICAL or GENERAL."; + + public static final Map HANDLERS = Map.of( + Category.BILLING, "You are a billing specialist. Answer in two sentences and quote the invoice rule that applies.", + Category.TECHNICAL, "You are a support engineer. Answer in two sentences and name the first thing to check.", + Category.GENERAL, "You are a friendly support agent. Answer in two sentences and offer to connect a specialist."); + + public record Routed(Category category, boolean fellBack, String rawLabel, String answer) {} + + private final ChatClient chat; + + public Router(ChatClient chat) { + this.chat = chat; + } + + public Routed handle(String message) { + String raw = chat.prompt().system(CLASSIFY).user(message).call().content(); + Category category; + boolean fellBack = false; + try { + category = Category.valueOf(raw.trim().toUpperCase(Locale.ROOT).replaceAll("[.!\\s]+$", "")); + } catch (RuntimeException notALabel) { // IllegalArgumentException or NullPointerException + category = Category.GENERAL; + fellBack = true; + } + String answer = chat.prompt().system(HANDLERS.get(category)).user(message).call().content(); + return new Routed(category, fellBack, raw, answer); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/CostTest.java b/agentic-patterns/src/test/java/com/ankurm/agentic/CostTest.java new file mode 100644 index 0000000..966b296 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/CostTest.java @@ -0,0 +1,73 @@ +package com.ankurm.agentic; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Runs every happy path with 100 ms of fake latency per call and tabulates what each pattern really did. */ +class CostTest { + + record Row(String name, ScriptedChatModel model) {} + + @Test + void callsStagesAndCharactersPerPattern() throws Exception { + List rows = new ArrayList<>(); + + var single = new ScriptedChatModel().latency(100).whenNoSystem("one answer"); + Scenarios.client(single).prompt().user(Scenarios.GIT_LOG).call().content(); + rows.add(new Row("single call (baseline)", single)); + + var chain = Scenarios.chain().latency(100); + new PromptChain(Scenarios.client(chain)).run(Scenarios.GIT_LOG); + rows.add(new Row("prompt chain", chain)); + + var router = Scenarios.router("TECHNICAL").latency(100); + new Router(Scenarios.client(router)).handle("The connector returns 401 since Monday"); + rows.add(new Row("routing", router)); + + var sectioning = Scenarios.sectioning().latency(100); + new Parallelizer(Scenarios.client(sectioning)).sectioning(Scenarios.CODE); + rows.add(new Row("sectioning x3", sectioning)); + + var voting = Scenarios.voting("SAFE", "SAFE", "UNSAFE").latency(100); + new Parallelizer(Scenarios.client(voting)).vote("Free crypto, click here", 3); + rows.add(new Row("voting x3", voting)); + + var orch = Scenarios.orchestrator().latency(100); + new Orchestrator(Scenarios.client(orch)).run(Scenarios.TASK); + rows.add(new Row("orchestrator, 3 steps", orch)); + + var evalOk = Scenarios.evaluatorConverging().latency(100); + new EvaluatorOptimizer(Scenarios.client(evalOk)).run(Scenarios.TASK, 5); + rows.add(new Row("evaluator, passes in round 3", evalOk)); + + var evalStuck = Scenarios.evaluatorStuck().latency(100); + new EvaluatorOptimizer(Scenarios.client(evalStuck)).run(Scenarios.TASK, 3); + rows.add(new Row("evaluator, hits the 3-round cap", evalStuck)); + + var t = new Transcript("12-cost-latency.txt"); + t.line("# calls, sequential stages and prompt characters per pattern (scripted model, 100 ms fake latency per call)"); + t.line("%-34s %5s %7s %9s %12s".formatted("pattern", "calls", "stages", "in flight", "prompt chars")); + for (Row r : rows) { + t.line("%-34s %5d %7d %9d %12d".formatted(r.name(), r.model().calls().size(), r.model().stages(), + r.model().maxInFlight(), r.model().totalChars())); + } + t.write(); + + assertThat(rows.get(0).model().stages()).isEqualTo(1); + assertThat(rows.get(1).model().calls()).hasSize(2); + assertThat(rows.get(1).model().stages()).isEqualTo(2); + assertThat(rows.get(2).model().stages()).isEqualTo(2); + assertThat(rows.get(3).model().calls()).hasSize(3); + assertThat(rows.get(3).model().stages()).isEqualTo(1); + assertThat(rows.get(4).model().stages()).isEqualTo(1); + assertThat(rows.get(5).model().calls()).hasSize(5); + assertThat(rows.get(5).model().stages()).isEqualTo(5); + assertThat(rows.get(6).model().calls()).hasSize(6); + assertThat(rows.get(6).model().stages()).isEqualTo(6); + assertThat(rows.get(7).model().calls()).hasSize(6); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/EvaluatorOptimizerTest.java b/agentic-patterns/src/test/java/com/ankurm/agentic/EvaluatorOptimizerTest.java new file mode 100644 index 0000000..1061dc1 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/EvaluatorOptimizerTest.java @@ -0,0 +1,57 @@ +package com.ankurm.agentic; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluatorOptimizerTest { + + @Test + void loopStopsOnTheFirstPass() throws Exception { + var model = Scenarios.evaluatorConverging(); + var result = new EvaluatorOptimizer(Scenarios.client(model)).run(Scenarios.TASK, 5); + + var calls = model.calls(); + assertThat(calls).hasSize(6); + assertThat(calls).extracting(c -> c.system).containsExactly( + EvaluatorOptimizer.GENERATOR, EvaluatorOptimizer.EVALUATOR, + EvaluatorOptimizer.GENERATOR, EvaluatorOptimizer.EVALUATOR, + EvaluatorOptimizer.GENERATOR, EvaluatorOptimizer.EVALUATOR); + assertThat(calls.get(0).user).isEqualTo(Scenarios.TASK); + assertThat(calls.get(2).user).isEqualTo(Scenarios.TASK + + "\nPrevious draft: Added a --json flag to the export command\nFeedback: Use the imperative mood.\nRevise the draft."); + assertThat(calls.get(3).user).isEqualTo("Add a --json flag to the export command."); + assertThat(result.converged()).isTrue(); + assertThat(result.rounds()).isEqualTo(3); + assertThat(result.best()).isEqualTo("Add --json output flag"); + + var t = new Transcript("10-evaluator-converges.txt"); + t.line("# evaluator-optimizer: three rounds, PASS on the third"); + t.calls(model); + t.line("converged: " + result.converged() + ", rounds: " + result.rounds() + ", calls: " + calls.size()); + t.line("best: " + result.best()); + t.line("feedback: " + result.feedback()); + t.write(); + } + + @Test + void aJudgeThatNeverPassesIsStoppedByTheRoundCap() throws Exception { + var model = Scenarios.evaluatorStuck(); + var result = new EvaluatorOptimizer(Scenarios.client(model)).run(Scenarios.TASK, 3); + + assertThat(model.calls()).hasSize(6); // the cap, not the judge, ended the loop + assertThat(result.converged()).isFalse(); + assertThat(result.rounds()).isEqualTo(3); + assertThat(result.feedback()).containsExactly("Longer than 50 characters.", "Looks good to me!", "Still longer than 50 characters."); + assertThat(result.best()).isEqualTo("Add a --json output flag to the export command, CLI"); + assertThat(result.drafts()).allSatisfy(d -> assertThat(d.length()).isGreaterThan(50)); + + var t = new Transcript("11-evaluator-max-rounds.txt"); + t.line("# evaluator-optimizer: the judge never says PASS, maxRounds = 3"); + t.calls(model); + t.line("converged: " + result.converged() + ", rounds: " + result.rounds() + ", calls: " + model.calls().size()); + t.line("best so far (returned, flagged): " + result.best() + " (" + result.best().length() + " chars)"); + t.line("feedback: " + result.feedback()); + t.write(); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/OrchestratorTest.java b/agentic-patterns/src/test/java/com/ankurm/agentic/OrchestratorTest.java new file mode 100644 index 0000000..b1c1656 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/OrchestratorTest.java @@ -0,0 +1,88 @@ +package com.ankurm.agentic; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrchestratorTest { + + @Test + void plannerDecidesTheStepsAndEachWorkerGetsOne() throws Exception { + var model = Scenarios.orchestrator(); + var result = new Orchestrator(Scenarios.client(model)).run(Scenarios.TASK); + + var calls = model.calls(); + assertThat(calls).hasSize(5); // 1 plan + 3 workers + 1 synthesis + assertThat(calls.get(0).system).isEqualTo(Orchestrator.PLANNER); + assertThat(calls.get(0).user).startsWith(Scenarios.TASK); + assertThat(calls.get(0).user).contains("JSON"); // entity() appended the format instructions + assertThat(calls.get(1).user).isEqualTo("Task: " + Scenarios.TASK + + "\nStep: Parse the flag\nInstruction: Register --json in the argument parser."); + assertThat(calls.get(2).user).contains("Step: Serialise rows"); + assertThat(calls.get(3).user).contains("Step: Document it"); + assertThat(calls.get(4).system).isEqualTo(Orchestrator.SYNTHESISER); + assertThat(calls.get(4).user).contains("## Parse the flag\nAdd a boolean option --json to ExportArgs."); + assertThat(result.fellBack()).isFalse(); + assertThat(result.plan().steps()).hasSize(3); + + var t = new Transcript("08-orchestrator-workers.txt"); + t.line("# orchestrator-workers: plan at run time, one worker per step, one synthesis"); + t.calls(model); + t.line("plan steps: " + result.plan().steps().stream().map(Orchestrator.Step::title).toList()); + t.line("calls: " + calls.size() + " (1 plan + " + result.stepResults().size() + " workers + 1 synthesis)"); + t.line("answer: " + result.answer()); + t.write(); + + var p = new Transcript("13-planner-prompt-as-sent.txt"); + p.line("# system message the planner call carried"); + calls.get(0).system.lines().forEach(p::line); + p.line("# user message the planner call carried: the task, then what entity(Plan.class) appended"); + calls.get(0).user.lines().forEach(p::line); + p.write(); + } + + @Test + void aMalformedPlanIsRetriedOnceThenAnsweredDirectly() throws Exception { + var t = new Transcript("09-orchestrator-malformed-plan.txt"); + + // (a) prose instead of JSON, then a good plan on the retry + var a = new ScriptedChatModel() + .when("You are a planner", "Sure! First I would parse the flag, then serialise the rows.", Scenarios.PLAN_JSON) + .when("carry out one step", "step result") + .when("Combine the step results", "final answer"); + var ra = new Orchestrator(Scenarios.client(a)).run(Scenarios.TASK); + assertThat(ra.planAttempts()).isEqualTo(2); + assertThat(ra.fellBack()).isFalse(); + assertThat(a.calls()).hasSize(6); // 2 plan + 3 workers + 1 synthesis + assertThat(a.calls().get(1).user).contains("Your previous reply could not be used (" + ra.planErrors().get(0) + ")"); + t.line("# (a) prose first, valid JSON on the retry"); + t.calls(a); + t.line("plan errors: " + ra.planErrors() + ", attempts: " + ra.planAttempts() + ", calls: " + a.calls().size()); + + // (b) valid JSON but seven steps: the plan validator rejects it, twice, then the direct fallback runs + String seven = "{\"steps\":[" + "{\"title\":\"s\",\"instruction\":\"i\"},".repeat(6) + "{\"title\":\"s\",\"instruction\":\"i\"}]}"; + var b = new ScriptedChatModel() + .when("You are a planner", seven) + .whenNoSystem("Just do it: one direct answer."); + var rb = new Orchestrator(Scenarios.client(b)).run(Scenarios.TASK); + assertThat(rb.fellBack()).isTrue(); + assertThat(b.calls()).hasSize(3); + assertThat(b.calls().get(2).system).isEmpty(); + assertThat(b.calls().get(2).user).isEqualTo(Scenarios.TASK); + t.line("# (b) seven steps twice: over the limit of " + Orchestrator.MAX_STEPS); + t.calls(b); + t.line("plan errors: " + rb.planErrors() + ", fell back: " + rb.fellBack() + ", answer: " + rb.answer()); + + // (c) prose both times + var c = new ScriptedChatModel() + .when("You are a planner", "I cannot produce JSON today.") + .whenNoSystem("Just do it: one direct answer."); + var rc = new Orchestrator(Scenarios.client(c)).run(Scenarios.TASK); + assertThat(rc.fellBack()).isTrue(); + assertThat(c.calls()).hasSize(3); + t.line("# (c) prose twice"); + t.calls(c); + t.line("plan errors: " + rc.planErrors() + ", fell back: " + rc.fellBack()); + t.write(); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/ParallelizationTest.java b/agentic-patterns/src/test/java/com/ankurm/agentic/ParallelizationTest.java new file mode 100644 index 0000000..ff8349c --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/ParallelizationTest.java @@ -0,0 +1,75 @@ +package com.ankurm.agentic; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ParallelizationTest { + + @Test + void sectioningRunsThreeReviewsAtOnceAndKeepsAspectOrder() throws Exception { + var model = Scenarios.sectioning().rendezvousOf(3); // times out if the calls ran one after another + var sections = new Parallelizer(Scenarios.client(model)).sectioning(Scenarios.CODE); + + assertThat(model.maxInFlight()).isEqualTo(3); + assertThat(model.calls()).extracting(c -> c.system) + .containsExactlyInAnyOrder(Parallelizer.aspectPrompt("security"), + Parallelizer.aspectPrompt("performance"), Parallelizer.aspectPrompt("readability")); + assertThat(model.calls()).allSatisfy(c -> assertThat(c.user).isEqualTo(Scenarios.CODE)); + assertThat(sections).extracting(Parallelizer.Section::aspect).containsExactly("security", "performance", "readability"); + + var t = new Transcript("05-sectioning.txt"); + t.line("# sectioning: three prompts, same input, merged in plain Java"); + t.callsUnordered(model); + t.line("calls: " + model.calls().size() + ", most calls in flight at once: " + model.maxInFlight()); + sections.forEach(s -> t.line(s.aspect() + ": " + s.text())); + t.write(); + } + + @Test + void aFailedBranchIsMarkedAndTheOthersSurvive() throws Exception { + var model = Scenarios.sectioning(); + model = new ScriptedChatModel() + .when("for security only", "SQL is built by string concatenation, which allows injection.") + .when("for performance only", new IllegalStateException("429 rate limited")) + .when("for readability only", "The query string should be a named constant."); + var sections = new Parallelizer(Scenarios.client(model)).sectioning(Scenarios.CODE); + + assertThat(sections).extracting(Parallelizer.Section::failed).containsExactly(false, true, false); + + var t = new Transcript("06-sectioning-branch-failure.txt"); + t.line("# sectioning: the performance branch throws"); + t.callsUnordered(model); + sections.forEach(s -> t.line(s.aspect() + (s.failed() ? " [FAILED] " : " ") + s.text())); + t.write(); + } + + @Test + void votingCountsBallotsAndATieIsUnsafe() throws Exception { + var t = new Transcript("07-voting.txt"); + t.line("# voting: the same prompt three times, majority wins, ties are UNSAFE"); + List cases = List.of( + new Object[]{"SAFE", "UNSAFE", "UNSAFE"}, + new Object[]{"SAFE", "SAFE", "Probably fine!"}, + new Object[]{"SAFE", "UNSAFE", "Hmm"}); + List expected = List.of("UNSAFE", "SAFE", "UNSAFE"); + for (int i = 0; i < cases.size(); i++) { + var model = Scenarios.voting(cases.get(i)).rendezvousOf(3); + var verdict = new Parallelizer(Scenarios.client(model)).vote("Free crypto, click here", 3); + + assertThat(model.maxInFlight()).isEqualTo(3); + assertThat(model.calls()).hasSize(3).allSatisfy(c -> { + assertThat(c.system).isEqualTo(Parallelizer.VOTE); + assertThat(c.user).isEqualTo("Free crypto, click here"); + }); + assertThat(verdict.verdict()).isEqualTo(expected.get(i)); + t.line("ballots %s -> %s (safe=%d, unsafe=%d, spoiled=%d)".formatted( + String.join(" | ", java.util.Arrays.stream(cases.get(i)).map(Object::toString).toList()), + verdict.verdict(), verdict.safe(), verdict.unsafe(), verdict.spoiled())); + } + t.line("calls per vote: 3, run at once"); + t.write(); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/PromptChainTest.java b/agentic-patterns/src/test/java/com/ankurm/agentic/PromptChainTest.java new file mode 100644 index 0000000..4df1698 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/PromptChainTest.java @@ -0,0 +1,49 @@ +package com.ankurm.agentic; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PromptChainTest { + + @Test + void chainRunsTwoCallsAndFeedsTheFirstAnswerIntoTheSecond() throws Exception { + var model = Scenarios.chain(); + var result = new PromptChain(Scenarios.client(model)).run(Scenarios.GIT_LOG); + + var calls = model.calls(); + assertThat(calls).hasSize(2); + assertThat(calls.get(0).system).isEqualTo(PromptChain.EXTRACT); + assertThat(calls.get(0).user).isEqualTo(Scenarios.GIT_LOG); + assertThat(calls.get(1).system).isEqualTo(PromptChain.WRITE); + assertThat(calls.get(1).user).isEqualTo(result.bullets()); // step 2 saw exactly what step 1 returned + + var t = new Transcript("01-prompt-chain.txt"); + t.line("# prompt chain: git log -> bullets -> gate -> summary"); + t.calls(model); + t.line("calls: " + calls.size()); + t.line("bullets: " + result.bullets().replace("\n", " / ")); + t.line("summary: " + result.summary()); + t.write(); + } + + @Test + void gateStopsTheChainBeforeTheSecondCall() throws Exception { + var model = new ScriptedChatModel() + .when("release-note bullets", "Sure! Here are the changes:\n- Added a --json flag"); + var chain = new PromptChain(Scenarios.client(model)); + + assertThatThrownBy(() -> chain.run(Scenarios.GIT_LOG)) + .isInstanceOf(PromptChain.GateFailedException.class) + .hasMessage("step 1 line is not a bullet: Sure! Here are the changes:"); + assertThat(model.calls()).hasSize(1); // the summary call was never made + + var t = new Transcript("02-chain-gate-failure.txt"); + t.line("# prompt chain: step 1 breaks the bullet format"); + t.calls(model); + t.line("gate: step 1 line is not a bullet: Sure! Here are the changes:"); + t.line("calls: " + model.calls().size() + " (the summary call was not made)"); + t.write(); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/RouterTest.java b/agentic-patterns/src/test/java/com/ankurm/agentic/RouterTest.java new file mode 100644 index 0000000..cf33514 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/RouterTest.java @@ -0,0 +1,54 @@ +package com.ankurm.agentic; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouterTest { + + @Test + void eachCategoryGetsItsOwnHandlerPrompt() throws Exception { + var t = new Transcript("03-routing.txt"); + t.line("# routing: one classification call, then one specialised call"); + String[][] cases = { + {"BILLING", "I was charged twice this month"}, + {"TECHNICAL", "The connector returns 401 since Monday"}, + {"GENERAL", "Do you have an office in Pune?"}}; + for (String[] c : cases) { + var model = Scenarios.router(c[0]); + var routed = new Router(Scenarios.client(model)).handle(c[1]); + + var calls = model.calls(); + assertThat(calls).hasSize(2); + assertThat(calls.get(0).system).isEqualTo(Router.CLASSIFY); + assertThat(calls.get(0).user).isEqualTo(c[1]); + assertThat(calls.get(1).system).isEqualTo(Router.HANDLERS.get(Router.Category.valueOf(c[0]))); + assertThat(routed.fellBack()).isFalse(); + + t.line("message: " + c[1]); + t.calls(model); + t.line("category: " + routed.category() + ", fell back: " + routed.fellBack()); + } + t.write(); + } + + @Test + void anUnusableLabelFallsBackToGeneralInsteadOfGuessing() throws Exception { + var t = new Transcript("04-routing-fallback.txt"); + t.line("# routing: what the classifier says versus where the message goes"); + String[][] labels = { + {"Technical.", "TECHNICAL"}, // sloppy but parseable: accepted + {"I think this is probably a billing question", "GENERAL"}, // prose: rejected, not substring-matched + {"REFUNDS", "GENERAL"}, // a label we do not have + {"", "GENERAL"}}; // empty reply + for (String[] l : labels) { + var model = Scenarios.router(l[0]); + var routed = new Router(Scenarios.client(model)).handle("The connector returns 401 since Monday"); + assertThat(routed.category().name()).isEqualTo(l[1]); + assertThat(routed.fellBack()).isEqualTo(l[1].equals("GENERAL")); + assertThat(model.calls().get(1).system).isEqualTo(Router.HANDLERS.get(routed.category())); + t.line("classifier said [" + l[0] + "] -> " + routed.category() + ", fell back: " + routed.fellBack()); + } + t.write(); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/Scenarios.java b/agentic-patterns/src/test/java/com/ankurm/agentic/Scenarios.java new file mode 100644 index 0000000..5b6a5e3 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/Scenarios.java @@ -0,0 +1,74 @@ +package com.ankurm.agentic; + +import org.springframework.ai.chat.client.ChatClient; + +/** The scripted conversations shared by the per-pattern tests and the cost test, so both measure the same thing. */ +final class Scenarios { + + static final String GIT_LOG = """ + abc123 add --json flag to the export command + def456 fix crash when the input file is empty + 0a1b2c refactor: rename Exporter fields"""; + + static final String CODE = "String q = \"select * from users where id=\" + id;"; + + static final String TASK = "Add a --json output flag to the export CLI"; + + static ChatClient client(ScriptedChatModel model) { + return ChatClient.create(model); + } + + static ScriptedChatModel chain() { + return new ScriptedChatModel() + .when("release-note bullets", "- Added a --json flag to the export command\n- Fixed a crash on empty input files") + .when("two-sentence summary", "Export can now write JSON with --json. Empty input files no longer crash it."); + } + + static ScriptedChatModel router(String label) { + return new ScriptedChatModel() + .when("Classify the support message", label) + .when("billing specialist", "Invoices are issued on the 1st; the duplicate charge will be refunded.") + .when("support engineer", "Check the connector's API key first; a 401 almost always means it expired.") + .when("friendly support agent", "Thanks for writing in. I will connect you with a specialist."); + } + + static ScriptedChatModel sectioning() { + return new ScriptedChatModel() + .when("for security only", "SQL is built by string concatenation, which allows injection.") + .when("for performance only", "select * pulls every column when one row is needed.") + .when("for readability only", "The query string should be a named constant."); + } + + static ScriptedChatModel voting(Object... ballots) { + return new ScriptedChatModel().when("safe to publish", ballots); + } + + static final String PLAN_JSON = """ + {"steps":[ + {"title":"Parse the flag","instruction":"Register --json in the argument parser."}, + {"title":"Serialise rows","instruction":"Write rows with Jackson when the flag is set."}, + {"title":"Document it","instruction":"Add --json to the help text and the README."}]}"""; + + static ScriptedChatModel orchestrator() { + return new ScriptedChatModel() + .when("You are a planner", PLAN_JSON) + .when("carry out one step", + "Add a boolean option --json to ExportArgs.", + "Use JsonMapper.writeValue(out, rows) when json is true.", + "Add one line to --help and a README example.") + .when("Combine the step results", "Three changes: ExportArgs gets --json, Exporter writes JSON, docs mention it."); + } + + static ScriptedChatModel evaluatorConverging() { + return new ScriptedChatModel() + .when("Write a git commit message", "Added a --json flag to the export command", "Add a --json flag to the export command.", "Add --json output flag") + .when("judge a commit message", "FAIL: Use the imperative mood.", "FAIL: Remove the trailing period.", "PASS"); + } + + static ScriptedChatModel evaluatorStuck() { + return new ScriptedChatModel() + .when("Write a git commit message", "Add a --json output flag to the export command of the CLI", + "Add a --json output flag to the export command of CLI", "Add a --json output flag to the export command, CLI") + .when("judge a commit message", "FAIL: Longer than 50 characters.", "Looks good to me!", "FAIL: Still longer than 50 characters."); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/ScriptedChatModel.java b/agentic-patterns/src/test/java/com/ankurm/agentic/ScriptedChatModel.java new file mode 100644 index 0000000..09ba243 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/ScriptedChatModel.java @@ -0,0 +1,181 @@ +package com.ankurm.agentic; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +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.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A ChatModel that never touches the network. Replies come from rules: the first rule whose text appears in the + * system prompt wins, and each rule hands out its scripted replies in order (the last one repeats). A scripted + * reply may be a RuntimeException, which is thrown instead of returned. Every prompt the model sees is recorded + * with a logical start and end tick, so tests can assert the exact prompt sequence and measure how many calls + * really ran one after another. + */ +final class ScriptedChatModel implements ChatModel { + + /** One call as the model saw it. Ticks come from one shared counter, so they order events across threads. */ + static final class Call { + final int index; + final String system; + final String user; + final long start; + volatile long end; + + Call(int index, String system, String user, long start) { + this.index = index; + this.system = system; + this.user = user; + this.start = start; + } + + int chars() { + return system.length() + user.length(); + } + } + + private static final class Rule { + final String systemContains; // null means: matches only calls that have no system message + final Deque replies = new ArrayDeque<>(); + Object last; + + Rule(String systemContains, Object[] scripted) { + this.systemContains = systemContains; + replies.addAll(List.of(scripted)); + } + + synchronized Object next() { + if (!replies.isEmpty()) { + last = replies.poll(); + } + return last; + } + } + + private final List rules = new ArrayList<>(); + private final List calls = Collections.synchronizedList(new ArrayList<>()); + private final AtomicLong tick = new AtomicLong(); + private final AtomicInteger inFlight = new AtomicInteger(); + private final AtomicInteger maxInFlight = new AtomicInteger(); + private long latencyMs; + private CountDownLatch rendezvous; + + ScriptedChatModel when(String systemContains, Object... replies) { + rules.add(new Rule(systemContains, replies)); + return this; + } + + ScriptedChatModel whenNoSystem(Object... replies) { + rules.add(new Rule(null, replies)); + return this; + } + + /** Every call sleeps this long between its start tick and its end tick, so parallel calls overlap. */ + ScriptedChatModel latency(long ms) { + this.latencyMs = ms; + return this; + } + + /** Every call blocks until n callers are inside call() at once. Sequential code would time out. */ + ScriptedChatModel rendezvousOf(int n) { + this.rendezvous = new CountDownLatch(n); + return this; + } + + List calls() { + synchronized (calls) { + return List.copyOf(calls); + } + } + + int maxInFlight() { + return maxInFlight.get(); + } + + /** Longest chain of calls where each one started after the previous one ended: the sequential depth. */ + int stages() { + List sorted = new ArrayList<>(calls()); + sorted.sort((a, b) -> Long.compare(a.start, b.start)); + int[] depth = new int[sorted.size()]; + int best = 0; + for (int i = 0; i < sorted.size(); i++) { + depth[i] = 1; + for (int j = 0; j < i; j++) { + if (sorted.get(j).end < sorted.get(i).start) { + depth[i] = Math.max(depth[i], depth[j] + 1); + } + } + best = Math.max(best, depth[i]); + } + return best; + } + + int totalChars() { + return calls().stream().mapToInt(Call::chars).sum(); + } + + @Override + public ChatResponse call(Prompt prompt) { + StringBuilder system = new StringBuilder(); + StringBuilder user = new StringBuilder(); + for (Message m : prompt.getInstructions()) { + if (m.getMessageType() == MessageType.SYSTEM) { + system.append(m.getText()); + } else if (m.getMessageType() == MessageType.USER) { + user.append(m.getText()); + } + } + Call call; + synchronized (calls) { + call = new Call(calls.size() + 1, system.toString(), user.toString(), tick.incrementAndGet()); + calls.add(call); + } + int now = inFlight.incrementAndGet(); + maxInFlight.accumulateAndGet(now, Math::max); + try { + if (rendezvous != null) { + rendezvous.countDown(); + if (!rendezvous.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("calls did not overlap: this code ran them one after another"); + } + } + if (latencyMs > 0) { + Thread.sleep(latencyMs); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } finally { + inFlight.decrementAndGet(); + call.end = tick.incrementAndGet(); + } + Object reply = scripted(call); + if (reply instanceof RuntimeException e) { + throw e; + } + return new ChatResponse(List.of(new Generation(new AssistantMessage((String) reply)))); + } + + private Object scripted(Call call) { + for (Rule rule : rules) { + boolean hit = rule.systemContains == null ? call.system.isEmpty() : call.system.contains(rule.systemContains); + if (hit) { + return rule.next(); + } + } + throw new AssertionError("no scripted reply for system=[" + call.system + "] user=[" + call.user + "]"); + } +} diff --git a/agentic-patterns/src/test/java/com/ankurm/agentic/Transcript.java b/agentic-patterns/src/test/java/com/ankurm/agentic/Transcript.java new file mode 100644 index 0000000..aa3abd6 --- /dev/null +++ b/agentic-patterns/src/test/java/com/ankurm/agentic/Transcript.java @@ -0,0 +1,49 @@ +package com.ankurm.agentic; + +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); + } + + /** One line per recorded call: its number, the system prompt and the user prompt, each cut to one short line. */ + void calls(ScriptedChatModel model) { + for (var c : model.calls()) { + line("call %d | system: %s | user: %s".formatted(c.index, clip(c.system), clip(c.user))); + } + } + + /** For parallel patterns: the order calls arrive in is not deterministic, so list them sorted and unnumbered. */ + void callsUnordered(ScriptedChatModel model) { + model.calls().stream() + .map(c -> "call | system: %s | user: %s".formatted(clip(c.system), clip(c.user))) + .sorted() + .forEach(this::line); + } + + static String clip(String s) { + String first = s.lines().findFirst().orElse(""); + String more = s.lines().count() > 1 ? " [+" + (s.lines().count() - 1) + " lines]" : ""; + String cut = first.length() > 56 ? first.substring(0, 56) + "..." : first; + return (cut.isEmpty() ? "(none)" : cut) + more; + } + + 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 b652cbc..03c39a7 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ langgraph4j adk code-review-bot + agentic-patterns