A from-scratch reimplementation of a coding-agent harness (in the spirit of Claude Code) on Java 25, built to understand the moving parts of an agentic CLI rather than to ship a product. The same harness runs against the Anthropic API or a local Ollama server, so the whole loop can be developed and tested offline, without spending tokens.
A harness here means everything around the model: the agent loop, the tool layer and its permission gate, conversation history and context compaction, streaming, and the terminal UI. The LLM is the engine; this repo is the chassis.
A sibling port — agent-code-cli-rust — implements the same design in Rust; see Rust sibling for the comparison.
- Is: a small, fully-tested agent runtime — agent loop, sealed tool layer with a permission gate, context window management, dual LLM backends, and a JLine REPL.
- Isn't: a production service, an SDK, or a Claude Code drop-in. There is no auth, no telemetry, no multi-user state. Scope is a single local workspace.
- Why it exists: to build the harness, not just use one — and to exercise Java 25 (records, sealed types, structured concurrency, AOT startup) on a real, concurrent, I/O-heavy problem.
┌─────────────────────────────────────────────────┐
stdin / CLI ─▶ │ App (Picocli) ─▶ REPL (JLine) | one-shot │
└───────────────────────┬─────────────────────────┘
│ prompt
▼
┌────────────────────────┐ summarize when
History ◀──────│ AgentLoop │◀── ≥75% of ctx ── Compactor
(Turn deque) │ gather → act → verify → │
│ repeat │
└─────┬─────────────┬─────┘
prompt │ │ tool_use
▼ ▼
┌────────────────┐ ┌────────────────────────────────┐
│ LlmGateway │ │ ToolRegistry + Gate │
│ (interface) │ │ (PermissionMode) │
├────────────────┤ ├────────────────────────────────┤
│ Anthropic (SDK,│ │ Read · Write · Bash · Glob · Grep│
│ SSE stream) │ │ executed in parallel via │
│ Ollama (HTTP, │ │ StructuredTaskScope, then gated │
│ NDJSON stream)│ │ by tool.readOnly() + mode │
└────────────────┘ └────────────────────────────────┘
Entry point: com.caiocv18.agentcode.App (Picocli). Build: Gradle 9.5.1 (Kotlin DSL), Java 25 with --enable-preview.
AgentLoop implements the classic cycle — gather context → call the model → run the tools it asks for → feed results back → repeat — until the model returns a terminal stop reason or maxIterations is hit.
- Append the user turn to
History; if the estimated context is≥ 75%ofmaxTokens,Compactorsummarizes the oldest turns first. - Send the prompt to the selected
LlmGateway; stream text deltas to the UI as they arrive. - If the reply's stop reason is
TOOL_USE, dispatch every requested tool in parallel viaStructuredTaskScope, gating each call throughGatebefore execution. - Append tool results as a new turn and loop.
- Terminate into a
ChatResult— a sealed type withDone,Truncated, orRefused, so the callerswitchis exhaustive on the model'sStopreason.
Turn and ChatResult are sealed interfaces with immutable record cases; conversation state is therefore pattern-matchable and copy-on-construct.
| Package | Type | Responsibility |
|---|---|---|
| (root) | AgentLoop |
The loop: model call ↔ parallel tool dispatch ↔ history/compaction, with ActivityListener lifecycle callbacks |
| (root) | App |
Picocli entry point; wires components and picks one-shot vs interactive path |
| (root) | ChatResult (sealed) |
Done / Truncated / Refused loop outcome |
llm/ |
LlmGateway (interface) |
Backend contract: Prompt/Reply/ToolDef records + Stop enum; streaming via a text-delta Consumer |
llm/ |
AnthropicLlmGateway |
Adapts Anthropic Java SDK 2.34.0; accumulates streamed tool-input JSON; maps SDK stop reasons |
llm/ |
OllamaLlmGateway |
HttpClient against Ollama 0.24+; parses NDJSON; extracts tool_calls by hand (no SDK) |
session/ |
History, Turn (sealed) |
Ordered turns; User/Assistant records carry tool results/uses |
context/ |
Compactor |
Token estimate (≈4 chars/token), summarizes old turns past the threshold, keeps the latest turn |
tools/ |
Tool, ToolRegistry |
LinkedHashMap registry; dispatch ToolUse → ToolResult, wrapping exceptions as ToolResult.error() |
tools/ |
ReadFileTool, WriteFileTool, BashTool, GlobTool, GrepTool |
The five built-ins; each declares readOnly() and a JSON input schema |
tools/ffm/ |
Ripgrep |
Subprocess bridge to rg --json behind a ProcessFactory seam (see note on FFM) |
permissions/ |
Permission, Gate, PermissionMode |
Allow/deny per call based on mode and tool.readOnly() |
tui/ |
ReplEngine, ChatPrompt, StreamingRenderer, ToolRenderer, theme/, markdown/ |
JLine REPL, incremental streaming output, tool-call tree, 12-color theme (with PT-BR aliases), markdown highlighting |
Backend behind one interface (LlmGateway), two implementations.
Anthropic and Ollama speak different protocols — SSE with event:/data: framing vs. one JSON object per line (NDJSON) — and Ollama needs no API key. Hiding both behind a single Prompt → Reply contract means AgentLoop never knows which backend it's talking to, the whole loop is developable offline against a local model, and tests stub HTTP with WireMock instead of hitting the network. Default backend is local Ollama (gemma2:2b); Anthropic is opt-in.
Tools as a closed set, dispatched by a registry.
Each tool implements a small Tool interface, declares readOnly(), and ships its own JSON-Schema input. ToolRegistry is the single dispatch point and the single place exceptions become ToolResult.error() — so a misbehaving tool degrades to an error message the model can read, never an uncaught throw that kills the loop.
A three-mode permission gate, keyed on readOnly().
PermissionMode is DEFAULT (prompt before any write/exec), AUTO_ACCEPT (run everything — batch/dev use), and PLAN (read-only; the agent may inspect and propose but not mutate). The gate reads tool.readOnly(), so adding a read-only tool needs no gate changes. Path-mutating tools additionally normalize-and-startsWith-check every path against the workspace root, rejecting ../ escapes and absolute paths.
Parallel tool calls via Structured Concurrency.
When the model requests several tools in one turn, they run concurrently inside a StructuredTaskScope (Java 25 preview): the scope joins all subtasks and propagates cancellation/timeout as a unit — no orphaned threads, no manual executor lifecycle. Single-tool turns take a plain synchronous path.
Context compaction instead of unbounded history.
The model's window is finite, so Compactor watches a cheap char-based token estimate and, past 75% of maxTokens, replaces the oldest turns with a text summary while preserving the system prompt and the most recent turn. Summaries are prose, not structured JSON, to stay legible when re-fed.
Testability designed in via SAM seams.
Subprocess-touching code (BashTool, Ripgrep) sits behind single-method factory seams (ProcessRunner/ProcessFactory) so tests inject deterministic fakes — fast/slow/timeout — without bytecode instrumentation. This kept the coverage gate (below) achievable without mocking the JDK.
A 95% JaCoCo gate as a forcing function.
jacocoTestCoverageVerification fails the build below 95% across all six counters (instruction, branch, line, complexity, method, class). Coverage stops being a vanity number and becomes part of "green"; in practice it pushed better seams and small testable helpers rather than test theater. Genuinely untestable terminal side-effect classes (App, SpinnerLoop, ChromeActivityListener) are explicitly excluded rather than faked.
Honest split between what the code uses and what was evaluated while reading the JEPs. (The README and design notes discuss the second group; the source does not wire it — calling it "used" would be a stretch.)
| JEP / feature | Status | Where / why |
|---|---|---|
| 395 Records | ✅ Used | ~17 record types (Prompt, Reply, ToolUse, ToolResult, Turn.*, ChatResult.*, …) — immutable data with copy-on-construct |
| 409 Sealed types | ✅ Used | Turn, ChatResult — exhaustive switch, compiler catches a new case left unhandled |
| 453 Structured Concurrency (preview) | ✅ Used | StructuredTaskScope in AgentLoop for parallel tool dispatch; requires --enable-preview |
| 483 / 514 / 515 AOT | ✅ Used (scripts) | aot-record/create/run scripts produce an AOT cache; cold start measured < 100 ms (hyperfine) |
| 506 Scoped Values | ⬜ Evaluated | Considered for session propagation; the MVP uses constructor injection + activity callbacks instead |
| 442 FFM | ⬜ Evaluated | Ripgrep calls rg --json as a subprocess (simple, deterministic, testable); a real FFM Linker binding is deferred |
| 519 Compact Headers · 521 Gen. Shenandoah · 502 Stable Values | ⬜ Evaluated | Discussed in the design notes; not configured/wired in the MVP build |
- JUnit 5 + AssertJ, 50 test files.
- Mockito 5.22 loaded as a
-javaagent(required on JDK 25 — see below). - WireMock stubs the LLM HTTP layer, so the full loop is tested end-to-end with deterministic replies and no network.
- JaCoCo 0.8.13 gate at ≥95% on six counters. Final coverage: instruction ≈97%, branch ≈96%, line ≈96%, complexity ≈96%, method ≈98%, class 100%.
./gradlew buildcompiles (--enable-preview), tests, reports, and verifies coverage; anything under threshold breaks the build. Every roadmap step landed green; all commits are SSH-signed.
The interesting part of building on a brand-new JDK is the friction. Each of these was a real blocker:
- Mockito couldn't mock on JDK 25. ByteBuddy in Mockito 5.14 didn't understand class-file format 69, and JDK 21+ restricts dynamic agent loading. Fix: upgrade to Mockito 5.22 and attach it as an explicit
-javaagentin thetesttask. - Anthropic SDK builders fail
.build()on "optional" fields. The Kotlin builders runcheckRequiredeven for fields the consumer treats as optional (citations,cacheCreation). Fix: in gateway tests, mockMessagedirectly (Mockito 5.22 + agent) rather than constructing it via the builder; public constants likeStopReason.END_TURNstay usable. **/*.javadidn't match top-level files. Java's glob requires at least one/after**, soa.javaslipped through. Fix: acompileGlobhelper rewrites**/<rest>to{**/,}<rest>(zero-or-one separator).- Grep tests hung forever. The ripgrep subprocess left
stderropen and the pipe filled and blocked. Fix: redirectstderrin the default factory and introduce theProcessFactoryseam, withRipgrep.alwaysUnavailable()as the test default. - JLine fell back to a "dumb terminal" under
gradle run.JavaExechands the REPL a closed stdin, so no real TTY. Fix: wirestandardInput = System.in, add a--promptone-shot flag for pipes, and prefer theinstallDistlauncher which inherits a real TTY. gemma2:2brejected tool calls with HTTP 400. Some Ollama models have no function-calling support and 400 when atoolsfield is present. Fix: a--toolsflag (off by default); for real tool-calling use a capable model (llama3.2:3b).- Ollama crashed on a 2 GB GPU. The runner tried to place a ~966 MiB compute graph on a GTX 750 Ti and died (
exit code 2). Fix: force CPU withnum_gpu=0(andCUDA_VISIBLE_DEVICES=-1), which is plenty since LLM latency, not the client, is the bottleneck.
# Build, test, and enforce coverage
./gradlew build
# Interactive REPL against a local Ollama model (default backend)
./gradlew run --args="--model llama3.2:3b --tools"
# One-shot (pipe-friendly), CPU-only
./gradlew run --args="--prompt 'list the build files' --num-gpu 0"
# Cold-start optimization (AOT cache)
scripts/aot-record.sh && scripts/aot-create.sh && scripts/aot-run.shRequirements: JDK 25 (Temurin), Gradle 9.5.1, and either an ANTHROPIC_API_KEY or a running Ollama 0.24+ (default http://host.docker.internal:11434, which is the WSL2 host). Flags: --model, --ollama-url, --num-gpu, --prompt, --tools, --permission-mode, --accent, --no-color.
agent-code-cli-rust is the same harness in Rust — useful as a controlled comparison of the same design under two runtimes.
| Aspect | Java 25 | Rust |
|---|---|---|
| Backend | Anthropic SDK or local Ollama | Local Ollama (trait leaves the door open for others) |
| Concurrency | StructuredTaskScope (preview) |
tokio::JoinSet |
ripgrep |
subprocess (rg --json) |
in-process crates (grep, globset, ignore) |
| Tools | Read · Write · Bash · Glob · Grep | + an edit tool with a colored diff |
| Distribution | JVM + classpath (AOT cache ~<100 ms start) | single static binary (~5–10 MB, strip + LTO + panic=abort, ~10 ms start) |
| Tests / coverage | JUnit5 + Mockito + WireMock, JaCoCo ≥95% | tokio + wiremock + insta, llvm-cov ≥90% |
| Logging | Logback | tracing (structured, RUST_LOG) |
- No real tokenizer — the compactor uses a 4-chars/token heuristic, which is approximate.
- FFM binding deferred —
ripgrepis a subprocess today; a nativeLinkerbinding is future work. - Some Java 25 features are evaluated, not wired (see the JEP table) — kept honest on purpose.
- Single local workspace, no concurrency across sessions, no persistence beyond the process.
- Preview features mean the build pins
--enable-preview; not for production use.