Repository navigation
Architecture
ACE (Agentic Coding Evaluation) is a Java 21 / Spring Boot backend plus a Vaadin 25 web front end. It drives a coding agent through a fixed, non-trivial build task (a small trading platform — Spring Boot
- PostgreSQL + Flyway + React + Docker Compose microservices), then scores the result with an oracle that never trusts the agent's own word for anything.
This page covers how the system is actually built, with citations into the real source. For the why behind the choices described here, see Design Decisions. For a screenshot-driven walkthrough of the UI, see the User Manual.
Both are Gradle subprojects of one root build (./gradlew :ace-service:..., ./gradlew :ace-ui-vaadin:...):
-
ace-service— the harness itself: job queue + worker, benchmark runner, scoring oracle, recording proxy, provider registry, and a REST API. Runs on port 8765. -
ace-ui-vaadin— a Vaadin 25 web UI that is a pure REST/SSE client oface-service. ~8,700 LOC across 14 routed views. Runs on port 8800 and talks toace-serviceover HTTP.
ace-service persists its own operational state (jobs, providers, experiments) in a local SQLite
database, migrated with Flyway (src/main/resources/db/migration/V1..V8__*.sql), queried through
jOOQ's DSLContext. This replaced an earlier PostgreSQL-backed store partway through development —
there is no external DB server or Testcontainers container behind ace-service's own data anymore
(PostgreSQL only reappears as something the agent's own generated stack uses, and as a real sidecar
the build-check container talks to — see §6).
Source layout (ace-service/src/main/java/com/strgmai/ace/service/):
| Package | Contents |
|---|---|
agent/ |
ReferenceAgent (in-process, LangChain4j) and PiAgent (external subprocess) — the two selectable harnesses; AgentTools, AgentSession, CompactionStrategies, ProviderStore
|
config/ |
BenchProperties (@ConfigurationProperties(prefix="ace")), JsonColumns (jOOQ JSON binding) |
docker/ |
DockerService — passive Docker-running probe, CLI-plugin mirroring |
metrics/ |
Collect, Trajectory, StatsService — cost, trajectory analysis, k-run statistics |
oracle/ |
CheckId (the 30-check registry), CheckStatus, CheckResult, Scorer, RunOracle
|
oracle/checks/ |
StructureChecks, MoneySafetyChecks, BuildChecks, ComposeChecks, BlackboxScenarios
|
pack/ |
Packs — stable pack, task pack, parallel-plan pack, review/trajectory-review packs, fix pack |
plan/ |
PlanParser, PlanTask, PlanError — plan-text parsing into topologically ordered tasks |
proxy/ |
RecordingProxy (journals every LLM request/response), SseAssembler
|
reference/ |
TradingService (+ 9 seeded bugs), TradingController, ReferenceServer (calibration twin) |
runner/ |
RunBench (orchestrator), RunBenchSupport, ContextProbe, Validity, VerifyTask, JournalFacts, Reviews, Judge, Intervene, PauseAfterTask, TaskMatcher
|
service/ |
JobQueue, WorkerService, ExperimentsService, ImporterService, BenchController, Preflight, RunSpec, Tailer, TreatmentPin, JobCfgFactory, TaskTranscript
|
The agent is driven to build a small trading platform to a frozen spec
(src/main/resources/task/PROMPT.md): Java 21 / Spring Boot 3 / Gradle, PostgreSQL + Flyway, a React
frontend, OpenAPI 3.1, Docker Compose, at least 3 microservices, with a frozen HTTP contract (account
creation, HALF_EVEN-rounded deposits, idempotent order repeats, asOf-scoped point-in-time holdings
queries, fixed price table, health endpoints). The task ladder (src/main/resources/tasks/ladder.json)
defines graduated rungs — L1_migration_entity … L7_full_platform, plus a plan-first variant
L3p_point_in_time — each a strict subset of the full 30-check, 77-weight registry. See
Features for the full check table.
A job is a single benchmark run (or one arm of an experiment), submitted via the UI's
Jobs → New Job form (POST /api/jobs, handled by BenchController) and tracked by
service/JobQueue.java and service/WorkerService.java:
-
JobQueueowns the persisted job rows (status: queued → running → succeeded/failed/cancelled, pluspaused) and the enqueue/cancel/pause/resume/requeue operations the REST layer and UI call into. -
WorkerServiceis the piece that actually turns a queued row into a live benchmark: it picks the next queued job, builds the run configuration, and hands it torunner/RunBench.javato execute.WorkerService's own guard enforcesPreflight's pinned-JDK check as a fatal gate — a job is refused outright, not just flagged in the UI, if the operator's environment doesn't have a usable JDK 21. -
RunSpec.javais the typed representation of a job's configuration — the--flag=valueargv-style list seen on every job detail page's "Run flags" line (model, harness, mode, task, budgets, judge, lesson-recording, compaction strategies, context-window, etc.) — andJobCfgFactory.javais what turns the raw form/API input into that spec. -
Preflight.javaruns a realgradle testagainst the positive-control fixture in the agent's own environment before any run is trusted to work at all — see Features for what it checks and User Manual: Providers & Preflight for the UI. -
Tailer.javastreams a running task's live log/transcript to the UI over SSE;TreatmentPin.javapins the comparability-relevant fields of a run's configuration (the "treatment") so later statistical comparisons can verify two runs are actually comparable.
Jobs run one at a time per ace-service instance (a single-run lock, see §4) — the queue serializes
work rather than running benchmarks in parallel on the same machine, since a benchmark run already
saturates the local model server and/or the Docker daemon on its own.
runner/RunBench.java (~2,500 lines) is the orchestrator. Two harness modes, selected by the
mode config value:
- Monolithic — one growing agent session handles planning and implementation together, with structural compaction triggered by a token threshold as context accretes.
-
Orchestrated — the plan is split into independent tasks, each its own session, composed from a
stable pack (byte-identical system-prompt prefix across every task, so the model server's
prefix cache serves it) plus a deterministic per-task pack (the task entry, its dependencies'
interfaces only, repo tree, previous task's diff, test verification, and progress notes). A fixed
INTEGRATIONtask always runs last. The class-level Javadoc also claims parallel task waves are supported (Packs.parallelPlanPack()plus merge/conflict-handling code inRunBench) — its correctness under load has not been independently verified.
Provenance and isolation, from RunBenchSupport.java:
- A scrubbed environment is built for every agent subprocess, with
ACE_RUN_IDset into it. - The workspace is git-snapshotted after every phase/task, so a run's artifact trail is reconstructible.
Separately, WorkerService.guard() acquires a single-run lock at ~/.cache/agentbench/run.lock
before a job is allowed to start, so concurrent runs never race for the same local model server.
(RunBenchSupport.acquireLock() exists in source but is currently unused — the real lock is the one
in WorkerService.)
Validity (runner/Validity.java) decides whether a finished run is even eligible to be scored or
pooled: a run is invalid (recorded, but never ranked) on a non-budget agent crash, a length finish
with no artifact, a dead recording proxy, an error rate above maxErrorRate, zero recorded LLM
requests, a request referencing another run's workspace, an unparseable plan, or — in orchestrated
mode — the INTEGRATION task never running. Budget exhaustion is explicitly not an invalidation;
it's treated as a real result (see Design Decisions).
Two selectable harnesses exist side by side, both constructor-injected into RunBench and picked
per run via --harness=:
-
agent/ReferenceAgent.java(AGENT_VERSION = "jls-ref-1.0") — an in-process LangChain4j agent against an OpenAI-wire-compatible endpoint. Six tools:read,write,edit(exact-match replace),bash(zsh, with timeout caps), plus two independently toggleable additions —recall_tool_output(recall an earlier tool call's output from the session journal after compaction dropped it) andrecord_lesson(report a cross-cutting finding — see §9). A turn with no tool call ends the session; the recording proxy's budget-exhaustion refusal ends it withrc=3; a hard 400-turn cap is the backstop. -
agent/PiAgent.java— drives an external agent subprocess, owning its lifecycle (start, HOME seeding, process-tree kill on stop) and folding its output into the same journal/session machinery.
CompactionStrategies.apply() runs whichever strategies are enabled, in order, once the last prompt
exceeds the configured compaction_strategies trigger. Four mechanisms exist (CompactionStrategies.KNOWN);
three are on by default:
| Strategy | Default | What it does |
|---|---|---|
tool_result_mask |
on | Stubs the oldest tool outputs once they fall outside a token-budgeted protected window; also stubs a write call's own full-file argument once its result is masked. |
error_line_extraction |
on | When a bash output's head/tail cap would let the omitted middle go dark, scans it for FAILED/Exception/assertion-diff/stack-frame lines and keeps the newest matches verbatim. |
tool_output_recall |
on | Makes recall_tool_output a real behavioural change — without it, a masked result's stub text just says "re-run the command" instead of pointing at a tool the model can call. |
structured_eviction |
opt-in | A one-shot LLM summary (LANDED/STATE/INVARIANTS/TASKS/DO NEXT/DECISIONS) of the evicted range, updated in place on later passes — the same shape as the inter-task handoff notes, applied within one session. |
runner/ContextProbe.java is step 0 of a benchmark run against a local model: it measures the
context window the current setup actually offers (min(server cap, memory), neither of which is
knowable from config alone) by sending real streamed chat-completion requests of growing length
(each a prefix-extension of the last, so the prefix cache keeps every step cheap), refining near the
first failure, and recording prefill/decode rate at every step. It owns the server's max_model_len
for the duration of the probe (raising it before measuring, restoring it after), and caches a result
keyed by checkpoint + settings + server version + host. The derived usable_context number then
flows directly into Packs.setWindow()/setScale() and the run's token budgets.
This step is oMLX-specific — prepareCap()/finalizeCap()/restartServer() shell out to a local
~/.omlx/bin/omlx binary and rewrite ~/.omlx/model_settings.json; against a remote provider there is
no such binary, so these become no-ops, and the probe is pointless anyway (it would just spend a
remote provider's paid tokens to rediscover a window size the operator already knows). --no-context-probe
plus an explicit --context-window=<N> is how every job against an external provider is run — see
User Manual: Jobs.
oracle/RunOracle.java is the single entry point (score(ws, task, systemBaseUrl, manifest)):
- Looks up the task's rung in
tasks/ladder.json— an unrecognized task throws, it never silently falls back to the full 77-point denominator. - Runs every offline check unconditionally (S1–S9, M1–M4, P1–P3) — no Docker needed. These are file-existence/size checks, directory-structure checks, and regex/text greps over (comment-stripped) source — none of S1–S9/M1–M4 does an AST parse.
- Probes Docker availability once (
docker info), as a single gate for every Docker-dependent check, rather than gating separately per script. - If Docker is reachable: runs
BuildChecks(B1/B2/B3) in a pinned JDK container with a real PostgreSQL sidecar, then — if any wanted id is C1/C2/F* —ComposeChecks(C1/C2 plus the black-box F-suite) against the agent's owndocker compose-built stack. - Any id that's still missing after that is
SKIPPED("docker unavailable") — excluded from the denominator but still blocking the headlineweighted_score_pct/functional_score_pct(seeoracle/CheckStatus.java). A checker that crashes fails every id it owns; it can never shrink the denominator by omission.
ace-service itself runs as a container (per docker-compose.yml) but never runs its own Docker
daemon. It reaches the host's real daemon by bind-mounting the host socket:
# docker-compose.yml
- /var/run/docker.sock:/var/run/docker.sockwith the container's user added to the socket's GID via group_add: ["${DOCKER_GID}"]. This is why a
managed container runtime (ECS/Fargate/App Runner) doesn't work as a deploy target — see
DEPLOY.md. From
inside that container, docker/DockerService.java shells out to the real docker/docker compose
CLI on PATH (reached through the ordinary scrubbed-environment PATH, no shim) to:
-
B1 —
gradle assembleinside a pinnedgradle:8.14-jdk21image, viadocker runwith the scratch-copied workspace and a shared Gradle-cache volume bind-mounted in. -
B2 —
gradle testthe same way, then verify results by parsing the real JUnit XML (**/build/test-results/**/*.xml) rather than trusting Gradle's exit code — agradle testthat exits 0 having run zero tests is not evidence of anything. -
B3 — a seeded mutation test: one of five syntactic mutations (e.g.
.subtract(→.add() applied to the arithmetic nearest aSELL/point-in-time-replay token in the agent's own source, requiring the agent's own test suite to catch it (a passing suite under a broken invariant means B3 fails). -
C1/C2 —
docker compose build/up -dthe agent's stack (from a source-only scratch copy — a jar built on the host could never legitimately ship in an image built from source), poll service health, then an HTTPGET /healthagainst it. -
F1–F9 —
oracle/checks/BlackboxScenarios.javadrives real HTTP scenarios against the agent's live stack: buy/sell symmetry, point-in-time queries, insufficient-balance 422, illegal-transition 409, exact decimal round-trips, OpenAPI conformance, HALF_EVEN deposit rounding, an exclusiveasOfboundary, and an idempotency-key repeat. Every scenario creates its own account (so scenarios are statistically independent) and reads time from the server's own response timestamp, never the oracle's clock.
docker_shim.sh — an older script that auto-started Docker Desktop on the agent's first docker call
— was removed entirely in a commit that also dropped all Docker-lifecycle
management from DockerService: ACE no longer starts or stops Docker Desktop itself, the operator
owns that. DockerService today only probes whether Docker happens to be running and mirrors the
Docker CLI plugins (e.g. docker compose) into the agent's fresh HOME.
reference/ReferenceServer.java is a plain JDK HttpServer wrapping the same TradingService the
real Spring controller serves, carrying nine seeded bugs
(float, selladd, no409, no422, asym, halfup, inclusive, noidem, hollow). A dedicated
BlackboxCalibrationTest proves, per bug, that exactly the scenario claiming to catch it fails, and
that zero bugs means every scenario passes — this calibrates the oracle's own scenarios against
themselves rather than scoring any agent.
oracle/Scorer.java turns a collection of CheckResults into the headline numbers:
-
denom= Σ weight over every counted (non-SKIPPED/INFRA) check;got= Σ weight wherePASS. -
pct = 100 * got / denom, rounded withHALF_EVEN. - The same ratio restricted to the functional ids (F1–F9) gives
functional_score_pct. - If any id was SKIPPED/INFRA, the run gets
partial_score_pct/partial_functional_score_pctinstead of the realweighted_score_pct/functional_score_pct— a partial run is never silently presented as a full score.
runner/Judge.java is a separate LLM session, run every N agent turns within a task
(judge_every_n_turns, default 10), that reads the
task's goal, budget, diff-so-far, and recent trajectory, and replies with exactly one verdict line
(ON TRACK / OFF TRACK) plus guidance injected as the agent's next turn when off-track. Consecutive
off-track verdicts tighten the check-in cadence; near budget exhaustion the judge switches to a
"triage mode" notice. Every checkpoint is written to judge/<taskId>-<attempt>-turn<N>.md — this is
what the UI's per-task Judge tab reads (see User Manual). judge_enabled
defaults to on; task-scoped sessions only (plan/handoff/review sessions never run it).
The per-task handoff mechanism only propagates a task's notes along its own
dependency edges, so a host/toolchain quirk one task discovers has no channel to reach a later,
unrelated task — every task was independently rediscovering the same facts. record_lesson(text) is
offered to the main task session, parallel-wave sessions, and both handoff paths; RunBench.recordLesson()
is the single canonical write path (Judge's own off-track recommendations and a budget-rescue
reconnect's reason are recorded through it too) into one run-wide lessons.md, folded into every
downstream task's pack. The file is size-capped (lessons_max_chars, default 6000); crossing the
ceiling triggers a deterministic, zero-LLM dedup pass that keeps the most recent of any duplicate
group — an LLM-based tier-2 compaction was deliberately deferred, since an LLM pass risks corrupting
the exact kind of factual record this feature exists to make trustworthy.
runner/Intervene.java is an in-memory mailbox, keyed by (runId, taskId), letting an operator inject
a live steering message into a running task's next turn — delivered through the same per-turn check
Judge uses, but with no LLM call and no enablement gate. It isn't persisted, but delivery is durably
logged into the task's own session JSONL. runner/PauseAfterTask.java is a graceful stop request: unlike
the immediate /pause endpoint (which can discard a task's in-progress turns), it waits for the
current task to finish, then calls the same JobQueue.pause() the immediate path uses. Both live on
the Job-detail page's Live tab.
agent/ProviderStore.java backs the LLM Providers tab: a Provider record (name, providerKey,
baseUrl, apiKey, defaultModel) persisted in a providers table (V7__add_providers.sql), seeded
with five external rows (openai, openrouter, anthropic, gemini, nebius). The local oMLX
server is not one of the seeded rows, but it's expected to be registered as an ordinary row too —
ProviderModelPicker.java (the UI's cascading model-selection control) hardcodes the literal key
"omlx" for it: a bare, unprefixed model string is treated as "omlx/" + itself (the same "no prefix
means local" assumption the backend already made before the registry existed, preserved for
pre-registry historical runs via V8__backfill_omlx_model_prefix.sql's one-time backfill), and if no
provider row with key omlx exists, the UI's main model field has no way to pick a local model at
all. Routing uses two distinct conventions by design:
-
route(raw)— the main coding-model field's convention: a bare string with no/is always a local model id; aprefix/reststring whose prefix matches a registeredproviderKeyroutes to that provider; any other slash-containing string (unknown prefix, or a local id that just happens to contain/) stays local. Never throws. -
resolveModel(raw, localFallback)— the reviewer/judge/task-matcher slot's convention: blank resolves to the local fallback; a bare known provider key expands via itsdefaultModel; a bare unknown key, or one with no default model, throws — a clear operator error rather than a silent fallback.
This is also the registry Preflight and job submission consult when deciding which provider
a --model string's prefix should be checked against — see User Manual: Providers.
proxy/RecordingProxy.java sits between the agent and the model server, journaling every
request/response (SseAssembler.java handles streamed-response reassembly) — this is what makes every
downstream claim ("what sampler parameters were actually sent," "how many tokens were generated")
verifiable against a recorded fact rather than configuration. metrics/Collect.java,
metrics/Trajectory.java, and metrics/StatsService.java compute cost/trajectory analysis and k-run
statistics: bootCi() (bootstrap confidence intervals), permutationTest(), and
requireMatchedBudgets() (harness-effect comparisons refuse unless budgets actually match, verified
numerically).
ace-ui-vaadin is a pure client of ace-service's REST API plus SSE for live state. 14 routed views
under com.strgmai.ace.ui: RootView (redirects to runs), RunsView/RunDetailView,
JobsView/JobNewView/JobDetailView, ExperimentsView/ExperimentNewView/ExperimentDetailView,
GroupsView, CompareView, PreflightView, LlmProvidersView, FileViewerView. DiffView is not a
route — it's the per-task dialog's diff-rendering helper, opened from JobDetailView rather than
navigated to directly. Live
state on the Jobs/Job-detail pages is SSE-driven (JobEventLoop.java, TaskTranscriptLoop.java), not
polling. CompareView doesn't do a naive id-for-id task match between two independently-decomposed
plans — runner/TaskMatcher.java is an LLM-based matcher that semantically aligns them first. Full
route table, form fields, and tab-by-tab breakdown are in the User Manual.
Operator (UI: New Job form)
│ POST /api/jobs
▼
BenchController ──▶ JobCfgFactory (raw input → RunSpec) ──▶ JobQueue (persisted, queued)
│
▼
WorkerService (picks up queued job; Preflight gate)
│
▼
RunBench.runOnce()
├─ ContextProbe (local provider only) ──▶ Packs.setWindow()/setScale()
├─ Packs.stablePack() / taskPack() ──▶ ReferenceAgent / PiAgent (LLM turns via RecordingProxy)
│ │ │
│ │ ├─ Judge (every N turns)
│ │ ├─ Intervene (operator steering)
│ │ └─ record_lesson ──▶ lessons.md
│ ▼
│ per-task git snapshot, session JSONL journal
▼
RunOracle.score() ──▶ StructureChecks / MoneySafetyChecks (offline)
└─ BuildChecks / ComposeChecks (Docker-gated, via DockerService DooD)
└─ BlackboxScenarios (HTTP against the agent's live compose stack)
│
▼
Scorer ──▶ manifest / oracle / metrics / trajectory written to results/<run-id>/
│
▼
ace-ui-vaadin (RunDetailView, GroupsView, CompareView, ExperimentDetailView) ── reads via REST/SSE
- Features — the full check table, provider registry, Preflight, every capability
- Design Decisions — the why behind the non-obvious choices above
- User Manual — screenshots and a tab-by-tab UI walkthrough
- docs/DESIGN.md — the source design reference this page summarizes
-
DEPLOY.md — deploying
ace-service+ace-ui-vaadinbehind Caddy on a plain VM