Features

Everything in the harness

This is not a roadmap. Every subsystem below is implemented, documented and exercised by a runnable example, and none of them adds a single jar to your classpath.

The core mechanism

Four annotations and a method call

There is no DSL to learn here. An agent is a class. The annotations tell the harness which parts of it the model can see, and generate(…) is where control passes from your code to the model and back again.

@SystemPrompt

Goes on the class, and its value is the system prompt. Supports two templating forms: {self.field} and {doc(self)}.

@Generative

Goes on a method. Its value is the instruction, its strategy selects how the call runs, and the method signature is the contract.

@Doc

Goes on an ordinary method or field to describe it to the model, playing the role a docstring would in Python.

@Hidden / @Shown

Control visibility. Members are visible to the model by default, so you hide the ones that are implementation detail.

the interception pointhow generate() works
@Generative("Write a one-sentence, friendly greeting for the given person.")
public String greet(String name) {
    return generate(name);
}

No magic, deliberately

generate(…) uses StackWalker to identify its calling method, reads that method’s @Generative instruction and declared return type by reflection, and dispatches to the configured strategy.

That is the whole trick. No dynamic proxy, no bytecode generation, no AOP weaving and no annotation processor, which means your stack traces are real, your breakpoints land, and there is nothing to debug but your own code.

Programmable loop engineering

Four strategies. Chosen per method.

Most frameworks give you one agent loop and a pile of configuration. jOpenAgent treats the loop as a decision you make per capability, because extracting a field from a string and reconciling two ledgers are not the same problem.

PREDICT, the default

A single structured-output call. The declared return type becomes a JSON schema, the response is bound onto it, and invalid output triggers a bounded retry with the parse or validation error fed back to the model.

Reach for it when the task is one transformation: extract, classify, summarise, rewrite.

TOOL_CALLING

The classic function-calling loop, run against the agent’s own visible methods plus the tools of any attached MCP server. All of them are exposed as JSON-schema tools derived from the real signatures.

Reach for it when the model needs to look things up in your system before it can answer.

CODE_ACT

The model writes Java, executed by a JShell-backed sandbox with a live self bound to your agent instance. It iterates through an execute_java tool, seeing real results, until it calls return_result.

Reach for it when the task needs loops, conditionals or composition. One round trip instead of six.

REFLEXION

Generate with a configurable base strategy, produce a structured self-critique, then retry with that feedback, up to three attempts. The critique is itself a typed, traced call, not a free-text aside.

Reach for it when quality matters more than latency and the model can check its own work.
MixedAgent.javaone agent, three strategies
@Generative("Classify the ticket's urgency.")                        // PREDICT (default)
public Urgency classify(String ticket) { return generate(ticket); }

@Generative(value = "Look up the customer and answer their question.",
            strategy = StrategyKind.TOOL_CALLING)
public String answer(String question) { return generate(question); }

@Generative(value = "Reconcile the two ledgers and report the discrepancy.",
            strategy = StrategyKind.CODE_ACT)
public Discrepancy reconcile() { return generate(); }

Strategy is a plain interface. If none of the four fits your problem, implement it yourself. The harness hands you the prompt, the typed contract, the tracer and the retry budget.

Typed input & output

The signature is the schema

ObjectBinder reflectively binds and validates LLM JSON against records, POJOs, List, Set, Map, Optional, enums, primitives and nested combinations of all of them. There is no registration step, no generated code, and no per-type adapter to write.

When the model returns something that does not fit, the harness does not throw at you. It retries, with the actual parse or validation error included in the follow-up prompt, up to a bound you configure. If it still cannot converge you get a GenerationException rather than a plausible-looking guess.

  • Records for outputs: immutable, concise, bound through the canonical constructor.
  • SchemaGenerator derives the JSON schema from the type, with real field names.
  • Typed inputs as well, since parameter names and types go into the prompt.
nested types bind tooorg.jopenagent.json
public record LineItem(String sku, int quantity, double unitPrice) { }

public record Invoice(String number,
                      LocalDate issuedOn,
                      List<LineItem> lines,
                      Optional<String> poReference,
                      Currency currency) { }

@Generative("Extract the invoice from this scanned document text.")
public Invoice extract(String documentText) {
    return generate(documentText);
}

Why this matters more in Java than in Python

Python frameworks need a validation library to express these shapes. Java already has them in the language. Your agent’s output type can be the same record your persistence layer and your REST API already use, so there is one definition and no drift.

Context & progressive disclosure

Control exactly what the model sees

self.context holds named blocks that get rendered into the system prompt. Static blocks are set once with put. Dynamic blocks are set with setDynamic and re-evaluated on every prompt build, so the model always reads current state instead of a snapshot you forgot to refresh.

Agent#describe(), also available inside a prompt as {doc(self)}, renders the agent’s visible fields and methods so the model knows what it can reach for. Members are visible by default. @Hidden removes one and @Shown puts it back.

This is what “pass by reference” means in practice. The model gets a bounded description of a live object graph, and the full values stay in the JVM instead of being serialised into the context window.

SprintAgent.javastatic and dynamic blocks
agent.context.put("plan",
        "Sprint goal: ship the billing export by Friday.");

// re-evaluated every time a prompt is built
agent.context.setDynamic("backlog", "self.formatBacklog()");

System.out.println(agent.suggestNextStep());

agent.addBacklogItem();
System.out.println(agent.context.get("backlog"));
// -> "6 item(s) remaining"

Explicit object state

Memory that survives the process

self.memory gives every agent remember, recall, update and forget, with similarity-based deduplication on write and recall weighted by both importance and recency.

  • Offline by default. An in-memory store and a deterministic HashingEmbedder, so there is no network, no API key, and your tests stay fast and hermetic.
  • Persist by swapping a store. JsonFileMemoryStore gives you cross-process persistence with no schema migration.
  • Real embeddings by swapping one builder call. OpenAiEmbedder works against the OpenAI API or any OpenAI-compatible server, including a local LM Studio or Ollama embedding model with no key.
swapping the embedderno other code changes
AgentConfig.builder()
        .llmClient(llm)
        .embedder(OpenAiEmbedder.forLmStudio("text-embedding-nomic-embed-text-v1.5",
                                             "http://localhost:1234"))
        .build();

Context that manages itself

History with a token budget

self.history accumulates across every CODE_ACT and TOOL_CALLING call on an agent instance. Once it crosses a configurable token or entry threshold, HistorySummarizer collapses the oldest entries into a single model-produced summary. It is structured as Summary, Topics, Outcomes and State, so the compression stays legible instead of being lossy in unpredictable ways.

You do not write pruning logic, and you do not discover at 3am that a long-running agent quietly blew past its context window.

a summarised entryself.history
Summary:  Investigated the failing nightly export.
Topics:   billing export, CSV encoding, timezone offsets
Outcomes: identified a UTC/local mismatch in the cutoff query
State:    awaiting confirmation of the reporting timezone

Skills

Instructions loaded on demand

Attach a TextSkill, which is a SKILL.md bundle with frontmatter and a markdown body, and the model sees only its name and description until it decides it needs the full instructions. That is progressive disclosure applied to prompts: a library of twenty procedures costs you twenty lines of context instead of twenty documents.

Skills can carry executables too. runScript(…) launches a script from the skill’s scripts/ directory as a real subprocess with a genuine hard-kill timeout, and readFile is sandboxed to the skill’s own directory.

The same format powers the skill we ship for AI coding assistants, which is a detail we rather like.

Model Context Protocol

A real MCP client, no SDK

McpClient is a direct stdio JSON-RPC 2.0 implementation covering initialize, tools/list and tools/call, attached to your agent as a plain field. Its tools are merged automatically into the TOOL_CALLING schema list as fieldName__toolName, and are callable straight from generated code as self.wiki.callTool(…).

It has been verified end to end against the official @modelcontextprotocol/server-everything reference server. On Windows, npx-based servers get routed through cmd.exe automatically, so you write the same code on every platform.

Multimodal

Images are parameters, not prompt text

Declare an Image, Audio, Video or FileAttachment parameter on a @Generative method and it gets attached to the request as real media content, base64 or URL, in each provider’s native wire format. It never gets stringified into the text prompt. Both the Anthropic and OpenAI clients are wired for it.

ReceiptAgent.javaorg.jopenagent.llm.media
@Generative("Read the receipt and extract the merchant, date and total.")
public Receipt read(Image photo) {
    return generate(photo);
}

// ...
Receipt r = agent.read(Image.fromFile(Path.of("receipt.jpg")));

Observability

Traced by default, exported four ways

Every LLM call, tool invocation, sandbox execution and generative method is a Span with explicit parent and child nesting. You do not switch tracing on. You only decide where the tree goes.

JSON

JsonTraceExporter writes the plain span tree. This is the zero-setup option: print it and read it.

ATIF v1.7

AtifExporter emits the agent-trace interchange format that NOOA itself produces, using the same wire schema, so trajectories stay portable.

OTLP / HTTP

OtlpHttpExporter speaks standard OTLP JSON to any OpenTelemetry collector you already run.

Langfuse

LangfuseExporter is OTLP preconfigured for Langfuse. Keys in, traces out.

A web trace viewer, in the box

TraceViewerServer is a real web app built entirely on the JDK’s own com.sun.net.httpserver. No Node, no React, no bundler, no build step. It lists traces, renders the selected one as a span tree, accepts OTLP posts from other processes, browses any attached MemoryStore, and holds free-text annotations and tags on a trace.

A desktop viewer too

TraceViewerSwingApp shows the same span tree in a Swing frame, also with zero dependencies. Register it as a TraceExporter to watch traces live in the current JVM, or point it at a running viewer server’s URL to poll traces from another process.

And a terminal one

TerminalTraceExplorer pretty-prints a span tree to any PrintStream, which is what you actually want in CI logs, in a container, or over SSH at the moment something goes wrong.

Evaluation

Prompt changes become measurements

Define an EvalSuite, which is an experiment, made of EvalCases. Run it with EvalRunner against a Scorer and get results back directly. ExactMatchScorer, ContainsScorer and NumericToleranceScorer ship with it, and a custom scorer is one method.

There is deliberately no separate results database. Each case is its own eval_case trace span carrying experiment and eval.* attributes, so the results are traces. The trace viewer’s Experiments tab reads them to show pass rates and a per-model, per-tier breakdown, and links straight into the underlying trace of any individual failure.

  • One artefact to store, browse and diff instead of two.
  • A failing case sits one click away from the exact prompt and response that caused it.
  • Compare models on the same suite without changing a line of agent code.
an experimentorg.jopenagent.eval
EvalSuite<String> suite = EvalSuite.<String>builder("ticket-triage-v1")
        .addCase(EvalCase.builder("billing-1", new EvalTask<String>() {
            @Override
            public String run() {
                return agent.classify("I was charged twice this month.");
            }
        }).displayName("Duplicate charge")
          .expected("billing")
          .tier(Tier.STABLE)
          .metadata("difficulty", "easy").build())
        .build();

List<EvalResult<String>> results =
        EvalRunner.run(suite, new ContainsScorer(), agent.getLlmClient().modelName());

for (EvalResult<String> result : results) {
    System.out.println((result.isPassed() ? "PASS" : "FAIL")
            + "  " + result.getCaseId()
            + "  expected=" + result.getExpected()
            + "  actual="   + result.getActual());
}
Open the Experiments tab in the built-in trace viewer to see the same run as a pass-rate table with a link into every individual trajectory.

Sandbox & safety

Two execution modes, and an honest account of both

IN_PROCESS, the default

Generated Java runs in an embedded JShell engine in the current JVM, with self bound to your live agent. It is fast, simple, and the right choice while you are developing.

CodeValidator applies a source-level denylist of dangerous packages and classes before anything executes.

OUT_OF_PROCESS

Generated code runs in a child JVM, talking to the parent over a bidirectional JSON-RPC channel on a loopback socket, so calls back into self still work transparently.

The reason to switch is that a timeout becomes a real process kill. The default engine can only ask a running snippet to yield at its next safepoint, which a tight infinite loop will never do.

What the sandbox is not. CodeValidator is defence-in-depth, not a containment boundary. A static source check cannot stop a determined program from reaching the filesystem or the network through reflection or another indirect route. OUT_OF_PROCESS upgrades timeout containment only, and adds no filesystem or network isolation. Always run agents that execute generated code inside OS-level isolation, meaning a container or a VM. The same applies to MCP: attaching a server grants that subprocess whatever permissions the JVM itself holds, so only attach servers you trust.

LLM clients

Model-agnostic, built on the JDK

LlmClient is a small interface with two implementations, both written entirely against java.net.http.HttpClient. There is no vendor SDK, no HTTP library and no reactive runtime, so nothing here can conflict with whatever your application already uses.

  • AnthropicClient covers the Messages API, including native media content.
  • OpenAiClient covers the Chat Completions API, which means real OpenAI and, by extension, any OpenAI-compatible server.
  • Local models are first class. OpenAiClient.forLmStudio(…) and .forOllama(…) need no API key and no account.
  • Embeddings follow the same pattern, with matching factories on OpenAiEmbedder.
switching providerone line
// development: free, local, nothing leaves the machine
LlmClient llm = OpenAiClient.forLmStudio("qwen/qwen3-coder-30b", "http://localhost:1234");

// staging: a small hosted model
LlmClient llm = new OpenAiClient(key, "gpt-4o-mini");

// production: a frontier model
LlmClient llm = new AnthropicClient(key, "claude-sonnet-4-5");

Test without a network at all

LlmClient is an interface, so a mock implementation is a few lines. The project’s own test suite uses exactly that to assert on retry behaviour, tool dispatch, strategy selection and wire formats, hermetically, in CI, with no keys.

Development experience

A skill so your AI assistant writes it correctly

This one is not a runtime feature, but it is the one that changes your day the most. The repository ships a SKILL.md file that teaches AI coding assistants the framework: the trampoline rule, which strategy fits which problem, how tool visibility works, the project’s hard constraints, and a table mapping six common symptoms to their real cause.

Copy it into your assistant’s skills folder or rules file, then describe the agent you want. It also works the other way round: point an assistant at an existing agent class and ask it to review against the conventions.

installone copy
cp -r skills/jopenagent .claude/skills/

Why it is needed

Models have read thousands of LangChain4j and Spring AI examples and almost no jOpenAgent. Without the skill they write a @Generative method with a real implementation in the body, or put the prompt in a javadoc comment that Java discards at compile time. Both are reasonable guesses. Both are wrong here.

See it all running

Nineteen examples, each with a main(), each run for real against a live model.