I did not build this to replace anyone. I built it because I wanted somewhere to be wrong about agents where being wrong is free. The playground is six LLM roles pointed at a real git repository. Product Owner, Tech Lead, Developer, PR Reviewer, QA, Stakeholder. Java 26, Spring Boot 4.1, LangChain4j 1.18. The Develop...
I did not build this to replace anyone. I built it because I wanted somewhere to be wrong about agents where being wrong is free. Check this out in the following git repo if you are curious on how this was implemented: https://github.com/josalero/learning-with-me/tree/main/software-dev-team The playground is six LLM roles pointed at a real git repository. Product Owner, Tech Lead, Developer, PR Reviewer, QA, Stakeholder. Java 26, Spring Boot 4.1, LangChain4j 1.18. The Developer edits files. A deterministic gate runs the project's own test command. The reviewer reads an actual git diff. It does not open pull requests on your service. It does not talk to your issue tracker. It does not deploy. Those are not missing features, they are the boundary that makes the sandbox worth having. The roles are a familiar shape to hang a workflow on, nothing more. What I actually walked away with is smaller than "an AI team" and far more reusable: two primitives, a GOAL and a LOOP, that change how any personal project uses an agent. GOAL and LOOP Every agent demo I have enjoyed and then quietly abandoned had the same hole. The agent decides when it is finished. That works right up until it does not, and you cannot tell the difference from the transcript. The model says the tests pass. The model says the docs are updated. The model says the bug is fixed. Sometimes that is true. You find out later, by hand. A GOAL is a condition your own code evaluates. Not a rubric in a prompt, not a self-assessment, not a confidence score. Something you could assert in a unit test with the model switched off. A LOOP is bounded iteration toward that goal. A worker attempts, a checker evaluates, and the whole thing has a cap and a name for what happens when the cap is reached. The pattern, minus the job titles That is the whole pattern. The interesting part is that the worker is the easiest piece to replace and the least important to get right. Swap models freely. The checker is what makes the run mean something. A Goal the Model Cannot Fake In this playground the goals are typed records, because a record with a compact constructor is the cheapest place to put a rule that a model cannot argue with. The Tech Lead cannot satisfy a brief with a convincing paragraph. AiSpec.covers(brief) is a plain Java check: every acceptance-criterion id appears once in traceability, with a planned test name attached. No planned test, the spec loop keeps going until the cap. The reviewer is the same idea. Findings carry a severity, only error and blocker count, and the constructor recomputes the decision rather than trusting what the model reported: public ReviewVerdict { findings = findings == null ? List.of() : List.copyOf(findings); blockingCount = (int) findings.stream() .filter(ReviewFinding::blocking) .count(); decision = blockingCount > 0 ? REQUEST_CHANGES : APPROVE; } QA is stricter still. If the planned test already ran on a green build, reconciliation upgrades the row. The model does not get a vote against JUnit XML: if (gate.success() && ran(planned, gate)) { results.add(new QaResult(criterion.id(), QaVerdict.PASS, evidence(planned, gate))); continue; } I have watched chat-shaped "QA agents" mark every criterion PASS and then fail the run with score=0 . Once the verdict is a record, that contradiction becomes impossible in the constructor instead of unlikely in a prompt. Prompts still matter, and mine restate the exit condition so the model is not fighting the record. The record still wins. A Loop That Knows How to Quit The implementation loop is LangChain4j loopBuilder with an exit condition written in Java, and a build gate that is an action rather than a chat model: java return AgenticServices.loopBuilder() .name("implementation-loop") .subAgents(agents.developer(role, context, policy), buildGateAction(context)) .maxIterations(policy.maxImplementationAttempts()) .testExitAtLoopEnd(true) .exitCondition(scope -> { BuildResult build = RunStateFactory.read( scope, StateKeys.BUILD_RESULT, BuildResult.none()); return build.success(); }) .build(); Review and QA wrap their rework in conditionalBuilder , so the Developer runs again only when the previous verdict actually asked for changes. Caps live in YAML because I wanted to tune the process without redeploying logic: yaml policy: maxSpecRework: 2 maxImplementationAttempts: 3 maxReviewCycles: 3 maxQaCycles: 3 maxStakeholderCycles: 2 qaPassThreshold: 80 stakeholderMode: AGENT Hitting a cap produces ESCALATED , which is a real outcome with artifacts attached, not a spinner that lies. An agent loop without a cap is a defect with a monthly bill. The Harness Around Both The application holds no product domain. It loads a team file and a project file, copies a seed into a gitignored workspace, and runs the roles as an agentic graph. The core module has no LangChain4j and no Spring, so the records and ports can be tested with no model in the room. System context Who is in the loop and what technology it runs against are separate axes on purpose. Change the roster without touching Java. Change Gradle for npm by swapping the seed and the project file. Composition vs technology Nested loops, each with its own goal: Four loops, four goals Watching a Loop Run A run starts with POST /api/v1/runs . Artifacts land under runs/<id>/ . Git commits locally and never pushes. The UI follows step events over SSE. Happy path The happy path is the boring one. The run I learn from is the one where the goal is already met and the loop refuses to believe it: Rework after the build is already green That second diagram is the reason the playground exists. You cannot see that shape in a chat window. Swap the Worker, Keep the Harness Workers are deliberately boring. The Developer is an @Agent interface whose tools are list, read, write, and delete inside a path jail. The prompt says plainly that running tests is not its job: @UserMessage(""" AI spec: {{aiSpec}} Review feedback (may be empty): {{reviewFeedback}} Use listFiles, readFile, writeFile, and deleteFile. Do not run tests; the build gate runs the allowlisted test command after your turn. Return JSON for ChangeSummary. """) @Agent(description = "Implements the AI spec by editing the repository", outputKey = StateKeys.CHANGE_SUMMARY) ChangeSummary implement( @V(StateKeys.AI_SPEC) AiSpec aiSpec, @V(StateKeys.REVIEW_FEEDBACK) String reviewFeedback, @V(StateKeys.BUILD_FEEDBACK) String buildFeedback); The checker on the other side is an argv array from project configuration, never a shell string the model composed: commands: build: ["./gradlew", "compileJava", "--console=plain"] test: ["./gradlew", "test", "--console=plain"] check: ["./gradlew", "check", "--console=plain"] if (!isAllowlisted(profile, argv)) { throw new CommandNotAllowedException( "Command argv is not allowlisted for project " + profile.id() + ": " + argv); } Tools are permissions, and permissions are the architecture. I would not give a brand new script unrestricted shell access on my laptop either. Other Things I Want to Put in This Loop The roles happen to be a scrum cell because that was the shape in front of me. The pattern does not care. Anywhere you can write a checker, you can run this, and most of my personal projects have at least one checker sitting unused in the build already. PlaygroundGoal your code checksWhat loopsDocs parityEvery public type and method has a doc comment with a sampleAgent edits comments, an AST scan re-runsCoverage on changed linesJaCoCo threshold on the diff, not the whole repoAgent adds tests, the report re-runsFlaky hunterSuite runs 20 times with identical resultsAgent quarantines or fixes, the runner repeatsMigration rehearsalCompile and tests gr...