Sunday, July 26, 2026

BUILDING OPENJAW: A MINIMALISTIC AGENTIC AI PLATFORM IN JAVA


Note: Some parts of the code were generated by an AI coding agent.

FOREWORD: WHY JAVA, WHY NOW, AND WHY AGENTS?

Let me be honest with you. When most people think about building AI agents, their minds jump straight to Python. LangChain, AutoGen, CrewAI — the Python ecosystem has dominated the agentic AI conversation for years. And look, I get it. Python is great. But here is the thing: the enterprise world runs on Java. Billions of lines of production code, decades of battle-tested libraries, and teams of developers who know Java inside and out are not going to rewrite everything in Python just because some AI framework happens to be more convenient there.

The good news — and this is genuinely exciting — is that 2026 is the year Java caught up, and in some ways surpassed Python for production agentic AI. Virtual Threads from Java 21 make concurrent I/O-bound agent tasks trivially scalable. The LangChain4j library (version 1.18.0 as of July 2026) provides a mature, idiomatic Java API for LLM integration. The Model Context Protocol (MCP) SDK version 0.17.0 gives us a standardized, vendor-neutral way to expose and consume tools. And the OpenAI Java SDK gives us first-class access to GPT-5.6 and beyond.

In this tutorial, we are going to build OpenJaw — a minimalistic but architecturally sound Agentic AI platform inspired by the Hermes Agent from Nous Research, but reimagined from the ground up in Java. We will take what Hermes does well (persistent memory, model-agnostic design, MCP tool integration, self-contained operation) and improve upon it with a clean hexagonal architecture, proper separation of concerns, structured concurrency, and support for both local LLMs via Ollama and remote LLMs via OpenAI and Anthropic.

Think of OpenJaw as your personal AI platform skeleton — something you can actually understand, extend, and trust in production. Not a black box. Not a framework that does magic behind the scenes. A real, readable, maintainable Java application that you built yourself.

Grab a coffee. This is going to be a long, rewarding ride.


CHAPTER ONE: UNDERSTANDING THE LANDSCAPE

1.1 What Is an Agentic AI Platform, Really?

Before we write a single line of code, we need to agree on what we are actually building. The term "agent" gets thrown around so loosely that it has almost lost meaning. Let us be precise.

A traditional LLM application is a simple pipeline: you send a prompt, you get a response, done. The LLM is a stateless function. It knows nothing about what happened before, it cannot take actions in the world, and it cannot decide for itself what to do next.

An agentic AI platform is fundamentally different in three ways.

First, it has agency — the ability to decide what to do next based on the current situation, the goal, and the available tools. The agent does not just respond; it reasons, plans, and acts.

Second, it has tools — the ability to interact with the external world. A web search tool, a file system tool, a database tool, a code execution tool. Tools are how an agent transcends the limitations of its training data and becomes genuinely useful for real-world tasks.

Third, it has memory — the ability to remember what happened before, both within a session (working memory) and across sessions (long-term memory). Without memory, every conversation starts from scratch, and the agent cannot learn or improve.

OpenJaw will implement all three of these capabilities in a clean, extensible way. The architecture will make it easy to add new tools, swap out LLM providers, and extend the memory system without touching the core business logic.

1.2 The Hermes Agent: What We Are Improving Upon

The original Hermes Agent from Nous Research (released February 2026) is genuinely impressive. It features a self-improving learning loop, persistent multi-layer memory, model-agnostic design through OpenAI-compatible endpoints, MCP tool integration, and support for multiple sandboxing backends. It runs as a persistent server process and gets smarter over time.

However, Hermes has some architectural limitations that we want to address in OpenJaw.

Hermes is not built around a formal architectural pattern. Its codebase mixes concerns in ways that make it harder to test, extend, and reason about. There is no clear separation between the domain logic (what the agent should do) and the infrastructure (how it communicates with LLMs and tools).

Hermes does not use MCP as its primary tool integration mechanism from the ground up. MCP support was added later, and it shows in the integration seams.

Hermes is Python-first. Java developers cannot easily embed it into existing enterprise systems or leverage Java's concurrency primitives for scalable agent execution.

OpenJaw addresses all of these by applying hexagonal architecture from the very beginning, treating MCP as a first-class citizen for tool integration, and leveraging Java 21's Virtual Threads and Structured Concurrency for scalable, readable concurrent agent execution.

1.3 The Technology Stack

Let us nail down exactly what we are using and why. As of July 2026, our stack is:

Java 21 is our runtime. It gives us Virtual Threads (stable since Java 21), Structured Concurrency (in preview, maturing rapidly), records, sealed interfaces, pattern matching, and text blocks. It is the minimum version required by most of our dependencies anyway.

LangChain4j 1.18.0 is our LLM integration layer. It provides a unified API across more than 20 LLM providers, built-in support for chat memory, tool calling, and MCP integration. It is the most mature Java LLM library available and is built idiomatically for Java rather than being a Python port.

MCP Java SDK 0.17.0 (io.modelcontextprotocol.sdk:mcp) is our tool integration protocol. MCP gives us a standardized way to define, expose, and consume tools, so our agent can work with any MCP-compatible tool server without custom integration code.

Ollama4j 1.1.6 (io.github.ollama4j:ollama4j) gives us access to locally running LLMs via Ollama, which is critical for privacy-sensitive deployments and offline operation.

Jackson 2.17.2 handles all our JSON serialization and deserialization, including the jackson-module-parameter-namesmodule for Java record support and jackson-datatype-jdk8 for Optional support.

SLF4J 2.0.13 with Logback 1.5.6 handles logging. Simple, standard, and universally understood.

Maven is our build tool. We use a Bill of Materials (BOM) approach to keep dependency versions aligned.

1.4 Hexagonal Architecture: The Foundation of Everything

Hexagonal Architecture, invented by Alistair Cockburn and also known as the Ports and Adapters pattern, is the architectural backbone of OpenJaw. Understanding it deeply is not optional — it is the key to understanding every design decision we make.

The core idea is beautifully simple: your application's business logic (the domain) should be completely isolated from the outside world. The domain does not know whether it is talking to a real database or a mock, a real LLM or a stub, a real web search tool or a test double. It only knows about abstract interfaces called Ports.

The outside world connects to the domain through Adapters. An adapter is a concrete implementation of a port that translates between the domain's language and the external system's language. A file-system adapter implements the memory port using JSON files. An OpenAI adapter implements the LLM port using the OpenAI API. An MCP adapter implements the tool port using the MCP protocol.

Here is a conceptual picture of the architecture:

+----------------------------------------------------------+
|                    EXTERNAL WORLD                        |
|                                                          |
|  [CLI]  [REST API]  [Telegram]  [Slack]  [Test Harness]  |
|    |         |          |          |           |          |
|    +----+----+----------+----------+-----------+          |
|         |                                                 |
|    DRIVING ADAPTERS (Primary Adapters)                   |
|         |                                                 |
|    +----v----+                                           |
|    |  INPUT  |                                           |
|    |  PORTS  |                                           |
|    +---------+                                           |
|         |                                                 |
|    +----v--------------------------------------------+   |
|    |              DOMAIN (Core)                      |   |
|    |                                                 |   |
|    |   AgentOrchestrator, ParallelToolExecutor       |   |
|    |                                                 |   |
|    +---------+-------------------------------+-------+   |
|              |                               |           |
|         OUTPUT PORTS                    OUTPUT PORTS     |
|              |                               |           |
|    +---------v---------+     +---------------v-------+   |
|    |   LLM ADAPTERS    |     |   TOOL ADAPTERS (MCP) |   |
|    |                   |     |                       |   |
|    | OpenAI  | Ollama  |     | WebSearch | FileSystem|   |
|    +-------------------+     +-----------------------+   |
+----------------------------------------------------------+

The domain sits in the middle, completely unaware of which adapters are plugged in. This means we can test the entire domain logic without any network calls, without any real LLMs, and without any real tools. We just plug in test doubles for all the ports.

This is not just academic elegance. In practice, it means that when OpenAI releases GPT-6 with a completely different API, we write one new adapter and nothing else changes. When we want to add a new tool, we write one MCP server and one adapter. When we want to support a new user interface, we write one driving adapter. The domain never changes.


CHAPTER TWO: PROJECT STRUCTURE AND SETUP

2.1 The Maven Project Structure

We organize OpenJaw as a multi-module Maven project. Each module has a clear, single responsibility, and the dependency graph flows strictly from the outside in — adapters depend on the domain, but the domain never depends on adapters.

Note carefully the naming convention: Maven artifact IDs use hyphens (openjaw-domainopenjaw-adapters-llm), while Java packages use dots (com.openjaw.domaincom.openjaw.adapters.llm). These are two completely separate naming systems and must never be confused.

The project layout looks like this:

openjaw/
├── pom.xml                                  (parent BOM)
├── openjaw-domain/
│   ├── pom.xml
│   └── src/
│       ├── main/java/com/openjaw/domain/
│       │   ├── model/
│       │   │   ├── Message.java
│       │   │   ├── ToolCall.java
│       │   │   ├── AgentMemory.java
│       │   │   └── ToolDefinition.java
│       │   ├── port/
│       │   │   ├── in/
│       │   │   │   └── RunAgentUseCase.java
│       │   │   └── out/
│       │   │       ├── LlmPort.java
│       │   │       ├── ToolPort.java
│       │   │       └── MemoryPort.java
│       │   └── service/
│       │       ├── AgentOrchestrator.java
│       │       └── ParallelToolExecutor.java
│       └── test/java/com/openjaw/domain/service/
│           └── AgentOrchestratorTest.java
├── openjaw-adapters-llm/
│   ├── pom.xml
│   └── src/main/java/com/openjaw/adapters/llm/
│       ├── langchain4j/
│       │   ├── LangChain4jLlmAdapter.java
│       │   └── LlmAdapterSelector.java
│       ├── openai/
│       │   └── OpenAiModelFactory.java
│       └── ollama/
│           └── OllamaModelFactory.java
├── openjaw-adapters-mcp/
│   ├── pom.xml
│   └── src/main/java/com/openjaw/adapters/mcp/
│       ├── client/
│       │   ├── McpToolAdapter.java
│       │   └── McpConnectionFactory.java
│       └── server/
│           └── WebSearchMcpServer.java
├── openjaw-adapters-memory/
│   ├── pom.xml
│   └── src/main/java/com/openjaw/adapters/memory/
│       └── filesystem/
│           └── FileSystemMemoryAdapter.java
├── openjaw-adapters-ui/
│   ├── pom.xml
│   └── src/main/java/com/openjaw/adapters/ui/
│       └── cli/
│           └── CliAdapter.java
└── openjaw-bootstrap/
    ├── pom.xml
    └── src/
        ├── main/java/com/openjaw/
        │   └── OpenJawApplication.java
        └── main/resources/
            └── logback.xml

The dependency rules are strict and enforced by Maven. The domain module has zero dependencies on any adapter module. The adapter modules depend on the domain module. The bootstrap module depends on all modules and is responsible for wiring everything together.

2.2 The Parent POM

File: openjaw/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <groupId>com.openjaw</groupId>
    <artifactId>openjaw</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <name>OpenJaw Agentic AI Platform</name>
    <description>
        A minimalistic, hexagonally-architected Agentic AI platform
        for Java, inspired by the Hermes Agent and improved for
        production use.
    </description>

    <modules>
        <module>openjaw-domain</module>
        <module>openjaw-adapters-llm</module>
        <module>openjaw-adapters-mcp</module>
        <module>openjaw-adapters-memory</module>
        <module>openjaw-adapters-ui</module>
        <module>openjaw-bootstrap</module>
    </modules>

    <properties>
        <java.version>21</java.version>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <langchain4j.version>1.18.0</langchain4j.version>
        <mcp.sdk.version>0.17.0</mcp.sdk.version>
        <ollama4j.version>1.1.6</ollama4j.version>
        <jackson.version>2.17.2</jackson.version>
        <slf4j.version>2.0.13</slf4j.version>
        <logback.version>1.5.6</logback.version>
        <junit.version>5.10.3</junit.version>
        <mockito.version>5.12.0</mockito.version>
    </properties>

    <dependencyManagement>
        <dependencies>

            <!-- LangChain4j BOM -->
            <dependency>
                <groupId>dev.langchain4j</groupId>
                <artifactId>langchain4j-bom</artifactId>
                <version>${langchain4j.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <!-- MCP Java SDK -->
            <dependency>
                <groupId>io.modelcontextprotocol.sdk</groupId>
                <artifactId>mcp</artifactId>
                <version>${mcp.sdk.version}</version>
            </dependency>

            <!-- Ollama4j for local LLM access -->
            <dependency>
                <groupId>io.github.ollama4j</groupId>
                <artifactId>ollama4j</artifactId>
                <version>${ollama4j.version}</version>
            </dependency>

            <!-- Jackson core -->
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>${jackson.version}</version>
            </dependency>

            <!-- Jackson Java 8 date/time support -->
            <dependency>
                <groupId>com.fasterxml.jackson.datatype</groupId>
                <artifactId>jackson-datatype-jsr310</artifactId>
                <version>${jackson.version}</version>
            </dependency>

            <!--
                Jackson Java 8 Optional support.
                Required to serialize/deserialize Optional<ToolCall>
                in AssistantMessage records.
            -->
            <dependency>
                <groupId>com.fasterxml.jackson.datatype</groupId>
                <artifactId>jackson-datatype-jdk8</artifactId>
                <version>${jackson.version}</version>
            </dependency>

            <!--
                Jackson parameter names module.
                Required to deserialize Java records without @JsonCreator.
                Works together with the -parameters compiler flag.
            -->
            <dependency>
                <groupId>com.fasterxml.jackson.module</groupId>
                <artifactId>jackson-module-parameter-names</artifactId>
                <version>${jackson.version}</version>
            </dependency>

            <!-- Logging -->
            <dependency>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-api</artifactId>
                <version>${slf4j.version}</version>
            </dependency>
            <dependency>
                <groupId>ch.qos.logback</groupId>
                <artifactId>logback-classic</artifactId>
                <version>${logback.version}</version>
            </dependency>

            <!-- Testing -->
            <dependency>
                <groupId>org.junit.jupiter</groupId>
                <artifactId>junit-jupiter</artifactId>
                <version>${junit.version}</version>
                <scope>test</scope>
            </dependency>
            <dependency>
                <groupId>org.mockito</groupId>
                <artifactId>mockito-core</artifactId>
                <version>${mockito.version}</version>
                <scope>test</scope>
            </dependency>

            <!-- Internal modules -->
            <dependency>
                <groupId>com.openjaw</groupId>
                <artifactId>openjaw-domain</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.openjaw</groupId>
                <artifactId>openjaw-adapters-llm</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.openjaw</groupId>
                <artifactId>openjaw-adapters-mcp</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.openjaw</groupId>
                <artifactId>openjaw-adapters-memory</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.openjaw</groupId>
                <artifactId>openjaw-adapters-ui</artifactId>
                <version>${project.version}</version>
            </dependency>

        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <release>21</release>
                    <compilerArgs>
                        <!--
                            Enable preview features for Structured Concurrency.
                            Also pass --enable-preview at JVM startup:
                            java --enable-preview -jar openjaw-bootstrap.jar
                        -->
                        <arg>--enable-preview</arg>
                        <!--
                            Preserve parameter names in bytecode.
                            Required by jackson-module-parameter-names
                            to deserialize Java records without @JsonCreator.
                        -->
                        <arg>-parameters</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.3.1</version>
                <configuration>
                    <argLine>--enable-preview</argLine>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Two compiler flags deserve special attention. --enable-preview is required for Structured Concurrency, which is still a preview feature in Java 21. -parameters preserves method parameter names in the compiled bytecode, which is required by jackson-module-parameter-names to automatically deserialize Java records without needing @JsonCreatorannotations on every record constructor. Without this flag, Jackson cannot figure out which JSON field maps to which record component, and deserialization of saved sessions will fail at runtime.


CHAPTER THREE: THE DOMAIN LAYER — THE HEART OF OPENJAW

3.1 Domain Models: Speaking the Language of Agents

The domain layer contains the pure business logic of our agent platform. It has no dependencies on any external library except the Java standard library and SLF4J for logging. No LangChain4j, no MCP SDK, no Jackson. Just plain Java. This is the most important constraint in hexagonal architecture, and it is what makes the domain layer so easy to test and reason about.

File: openjaw-domain/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.openjaw</groupId>
        <artifactId>openjaw</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>openjaw-domain</artifactId>
    <name>OpenJaw Domain</name>

    <dependencies>
        <!--
            The domain has ONLY logging as an external dependency.
            Everything else is pure Java standard library.
            This constraint is what makes the domain so testable.
        -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

File: openjaw-domain/src/main/java/com/openjaw/domain/model/Message.java

package com.openjaw.domain.model;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

/**
 * Represents a single message in an agent conversation.
 *
 * Messages are immutable value objects. Once created, they cannot be
 * modified. This is intentional — conversation history is append-only,
 * just like in real conversations.
 *
 * The sealed interface hierarchy ensures that every message type is
 * explicitly handled in switch expressions, giving us compile-time
 * exhaustiveness checking. Add a new message type and the compiler
 * immediately tells you every place that needs updating.
 */
public sealed interface Message permits
        Message.UserMessage,
        Message.AssistantMessage,
        Message.ToolCallMessage,
        Message.ToolResultMessage,
        Message.SystemMessage {

    UUID id();
    Instant timestamp();

    /**
     * A message from the human user to the agent.
     */
    record UserMessage(
            UUID id,
            Instant timestamp,
            String content
    ) implements Message {

        public static UserMessage of(String content) {
            return new UserMessage(UUID.randomUUID(), Instant.now(), content);
        }
    }

    /**
     * A message from the AI assistant.
     * May contain either a direct text response or a tool call request.
     */
    record AssistantMessage(
            UUID id,
            Instant timestamp,
            String content,
            Optional<ToolCall> toolCall
    ) implements Message {

        public static AssistantMessage ofText(String content) {
            return new AssistantMessage(
                UUID.randomUUID(), Instant.now(), content, Optional.empty()
            );
        }

        public static AssistantMessage ofToolCall(ToolCall toolCall) {
            return new AssistantMessage(
                UUID.randomUUID(), Instant.now(), "", Optional.of(toolCall)
            );
        }

        public boolean hasToolCall() {
            return toolCall.isPresent();
        }
    }

    /**
     * Records the agent's decision to invoke a specific tool.
     */
    record ToolCallMessage(
            UUID id,
            Instant timestamp,
            ToolCall toolCall
    ) implements Message {

        public static ToolCallMessage of(ToolCall toolCall) {
            return new ToolCallMessage(
                UUID.randomUUID(), Instant.now(), toolCall
            );
        }
    }

    /**
     * The result returned by a tool after execution.
     */
    record ToolResultMessage(
            UUID id,
            Instant timestamp,
            UUID toolCallId,
            String toolName,
            String result,
            boolean success
    ) implements Message {

        public static ToolResultMessage success(
                UUID toolCallId, String toolName, String result) {
            return new ToolResultMessage(
                UUID.randomUUID(), Instant.now(),
                toolCallId, toolName, result, true
            );
        }

        public static ToolResultMessage failure(
                UUID toolCallId, String toolName, String errorMessage) {
            return new ToolResultMessage(
                UUID.randomUUID(), Instant.now(),
                toolCallId, toolName, errorMessage, false
            );
        }
    }

    /**
     * A system-level instruction that shapes the agent's behavior.
     */
    record SystemMessage(
            UUID id,
            Instant timestamp,
            String content
    ) implements Message {

        public static SystemMessage of(String content) {
            return new SystemMessage(UUID.randomUUID(), Instant.now(), content);
        }
    }
}

File: openjaw-domain/src/main/java/com/openjaw/domain/model/ToolCall.java

package com.openjaw.domain.model;

import java.util.Map;
import java.util.UUID;

/**
 * Represents the agent's decision to call a specific tool.
 *
 * A ToolCall is produced by the LLM when it determines that it needs
 * external information or capabilities to answer the user's question.
 * The agent runtime then executes the tool and feeds the result back
 * to the LLM.
 */
public record ToolCall(
        UUID callId,
        String toolName,
        Map<String, Object> arguments
) {
    public static ToolCall of(String toolName, Map<String, Object> arguments) {
        return new ToolCall(UUID.randomUUID(), toolName, arguments);
    }
}

File: openjaw-domain/src/main/java/com/openjaw/domain/model/AgentMemory.java

package com.openjaw.domain.model;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

/**
 * Represents the agent's working memory for a single session.
 *
 * AgentMemory is the in-domain representation of a conversation.
 * It is intentionally simple: an ordered list of messages with a
 * session identifier. The complexity of how this memory is persisted,
 * compressed, or retrieved belongs in the memory adapter, not here.
 */
public final class AgentMemory {

    private final UUID sessionId;
    private final List<Message> messages;
    private final int maxMessages;

    public AgentMemory(UUID sessionId, int maxMessages) {
        this.sessionId = sessionId;
        this.messages = new ArrayList<>();
        this.maxMessages = maxMessages;
    }

    public void addMessage(Message message) {
        messages.add(message);
        if (messages.size() > maxMessages) {
            trimToLimit();
        }
    }

    /**
     * Trims the message list to maxMessages.
     * Preserves the system message at index 0 if one exists,
     * then removes the oldest non-system messages.
     */
    private void trimToLimit() {
        boolean hasSystemMessage = !messages.isEmpty()
                && messages.get(0) instanceof Message.SystemMessage;
        int startIndex = hasSystemMessage ? 1 : 0;
        while (messages.size() > maxMessages) {
            messages.remove(startIndex);
        }
    }

    public List<Message> getMessages() {
        return Collections.unmodifiableList(messages);
    }

    public UUID getSessionId() {
        return sessionId;
    }

    public int size() {
        return messages.size();
    }

    public boolean isEmpty() {
        return messages.isEmpty();
    }
}

File: openjaw-domain/src/main/java/com/openjaw/domain/model/ToolDefinition.java

package com.openjaw.domain.model;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Describes a tool that the agent can invoke.
 *
 * ToolDefinitions are provided to the LLM so it knows what tools are
 * available and how to call them. The LLM uses the name and description
 * to decide when to use a tool, and the parameterSchema to format its
 * tool call arguments correctly.
 *
 * One practical tip: write the description as if you are explaining the
 * tool to a smart colleague who has never seen it before. The quality of
 * your description directly determines how reliably the LLM will use the
 * tool correctly.
 */
public record ToolDefinition(
        String name,
        String description,
        Map<String, Object> parameterSchema
) {
    /**
     * Convenience factory for tools with a simple list of required
     * string parameters.
     */
    public static ToolDefinition simple(
            String name,
            String description,
            List<String> requiredStringParams) {

        LinkedHashMap<String, Object> properties = new LinkedHashMap<>();
        for (String param : requiredStringParams) {
            properties.put(param, Map.of("type", "string"));
        }

        Map<String, Object> schema = Map.of(
            "type", "object",
            "properties", properties,
            "required", requiredStringParams
        );

        return new ToolDefinition(name, description, schema);
    }
}

3.2 The Ports: Defining the Boundaries

File: openjaw-domain/src/main/java/com/openjaw/domain/port/out/LlmPort.java

package com.openjaw.domain.port.out;

import com.openjaw.domain.model.AgentMemory;
import com.openjaw.domain.model.Message;
import com.openjaw.domain.model.ToolDefinition;

import java.util.List;

/**
 * Output port for interacting with a Large Language Model.
 *
 * This interface is the single point of contact between the domain
 * and any LLM provider. Whether the underlying implementation uses
 * OpenAI's GPT-5.6, Anthropic's Claude, or a local Ollama model,
 * the domain sees exactly this interface and nothing else.
 */
public interface LlmPort {

    /**
     * Sends the current conversation history to the LLM and returns
     * the model's response.
     *
     * @param memory         The current conversation history.
     * @param availableTools The tools available to the model.
     * @return The model's response as an AssistantMessage.
     */
    Message.AssistantMessage chat(
            AgentMemory memory,
            List<ToolDefinition> availableTools
    );

    /**
     * Returns a human-readable identifier for this LLM provider.
     * Example: "openai/gpt-5.6-sol" or "ollama/llama3.3:70b"
     */
    String getProviderIdentifier();
}

File: openjaw-domain/src/main/java/com/openjaw/domain/port/out/ToolPort.java

package com.openjaw.domain.port.out;

import com.openjaw.domain.model.Message;
import com.openjaw.domain.model.ToolCall;
import com.openjaw.domain.model.ToolDefinition;

import java.util.List;

/**
 * Output port for discovering and executing tools.
 *
 * The domain uses this port to find out what tools are available
 * (from MCP servers) and to execute them when the LLM requests it.
 */
public interface ToolPort {

    /**
     * Returns all tools currently available from all connected MCP servers.
     */
    List<ToolDefinition> listAvailableTools();

    /**
     * Executes a tool call and returns the result.
     *
     * @param toolCall The tool invocation requested by the LLM.
     * @return The result of the tool execution.
     */
    Message.ToolResultMessage executeTool(ToolCall toolCall);
}

File: openjaw-domain/src/main/java/com/openjaw/domain/port/out/MemoryPort.java

package com.openjaw.domain.port.out;

import com.openjaw.domain.model.AgentMemory;

import java.util.Optional;
import java.util.UUID;

/**
 * Output port for persisting and retrieving agent memory.
 *
 * The simplest implementation stores memory in a JSON file on disk.
 * A more sophisticated implementation might use a vector database
 * for semantic retrieval. The domain does not care which one is used.
 */
public interface MemoryPort {

    void save(AgentMemory memory);

    Optional<AgentMemory> load(UUID sessionId);

    AgentMemory createNew(UUID sessionId);
}

File: openjaw-domain/src/main/java/com/openjaw/domain/port/in/RunAgentUseCase.java

package com.openjaw.domain.port.in;

import java.util.List;
import java.util.UUID;

/**
 * Input port (use case) for running the agent's reasoning cycle.
 *
 * This is the primary entry point into the domain. A driving adapter
 * (CLI, REST API, messaging platform) calls this use case when the
 * user sends a message to the agent.
 */
public interface RunAgentUseCase {

    /**
     * Processes a user message within an existing or new session.
     *
     * @param sessionId The conversation session identifier.
     * @param userInput The user's message text.
     * @return The agent's final response after completing its
     *         reasoning and tool-calling cycle.
     */
    AgentResponse run(UUID sessionId, String userInput);

    /**
     * The result of a single agent reasoning cycle.
     */
    record AgentResponse(
            UUID sessionId,
            String responseText,
            int toolCallCount,
            List<String> toolsUsed,
            long durationMillis
    ) {}
}

3.3 The Domain Service: The ReAct Loop

The ReAct (Reasoning + Acting) loop is the beating heart of any agentic AI system. The loop works like this: the agent receives a user message, sends it to the LLM along with the available tools, and the LLM either responds directly or requests a tool call. If it requests a tool call, the agent executes the tool, adds the result to the conversation history, and sends everything back to the LLM. This continues until the LLM produces a direct response or a maximum iteration limit is reached.

File: openjaw-domain/src/main/java/com/openjaw/domain/service/AgentOrchestrator.java

package com.openjaw.domain.service;

import com.openjaw.domain.model.AgentMemory;
import com.openjaw.domain.model.Message;
import com.openjaw.domain.model.ToolDefinition;
import com.openjaw.domain.port.in.RunAgentUseCase;
import com.openjaw.domain.port.out.LlmPort;
import com.openjaw.domain.port.out.MemoryPort;
import com.openjaw.domain.port.out.ToolPort;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

/**
 * The core domain service that implements the agent's ReAct loop.
 *
 * ReAct stands for Reasoning + Acting. The agent alternates between
 * asking the LLM to reason about the current situation and acting
 * by executing tools. This continues until the LLM produces a final
 * answer or the maximum iteration count is reached.
 *
 * This class has NO dependencies on any external library beyond SLF4J.
 * It only depends on the domain ports (interfaces) and domain models
 * (records). This is the purest expression of hexagonal architecture.
 */
public class AgentOrchestrator implements RunAgentUseCase {

    private static final Logger log =
        LoggerFactory.getLogger(AgentOrchestrator.class);

    /**
     * Safety limit: maximum number of LLM calls per user request.
     * Prevents infinite loops if the LLM keeps requesting tools
     * without ever producing a final answer.
     */
    private static final int MAX_ITERATIONS = 10;

    private static final String SYSTEM_PROMPT = """
        You are OpenJaw, an intelligent AI assistant with access to
        powerful tools. You can search the web, read files, and perform
        complex research tasks.

        When answering questions:
        1. Think carefully about whether you need to use a tool.
        2. If you need current information, use the web search tool.
        3. Always cite your sources when using tool results.
        4. Be concise but thorough in your responses.
        5. If a tool call fails, explain what happened and try an alternative.

        You are running on OpenJaw, a Java-based agentic AI platform
        built with hexagonal architecture and MCP tool integration.
        """;

    private final LlmPort llmPort;
    private final ToolPort toolPort;
    private final MemoryPort memoryPort;
    private final ParallelToolExecutor parallelToolExecutor;

    public AgentOrchestrator(
            LlmPort llmPort,
            ToolPort toolPort,
            MemoryPort memoryPort) {
        this.llmPort = llmPort;
        this.toolPort = toolPort;
        this.memoryPort = memoryPort;
        this.parallelToolExecutor = new ParallelToolExecutor(toolPort);
    }

    @Override
    public AgentResponse run(UUID sessionId, String userInput) {
        long startTime = System.currentTimeMillis();

        log.info("Starting agent run for session {} with input: {}",
            sessionId, userInput);

        // Load existing memory or create a fresh session.
        AgentMemory memory = memoryPort.load(sessionId)
            .orElseGet(() -> {
                log.debug("No existing memory for session {}, creating new.",
                    sessionId);
                AgentMemory fresh = memoryPort.createNew(sessionId);
                fresh.addMessage(Message.SystemMessage.of(SYSTEM_PROMPT));
                return fresh;
            });

        memory.addMessage(Message.UserMessage.of(userInput));

        // Discover available tools once per request.
        List<ToolDefinition> availableTools = toolPort.listAvailableTools();
        log.debug("Available tools: {}",
            availableTools.stream().map(ToolDefinition::name).toList());

        List<String> toolsUsed = new ArrayList<>();
        int iterationCount = 0;
        String finalResponse = null;

        while (iterationCount < MAX_ITERATIONS) {
            iterationCount++;
            log.debug("ReAct iteration {} for session {}",
                iterationCount, sessionId);

            Message.AssistantMessage llmResponse =
                llmPort.chat(memory, availableTools);
            memory.addMessage(llmResponse);

            if (llmResponse.hasToolCall()) {
                var toolCall = llmResponse.toolCall().get();
                log.info("LLM requested tool call: {} with args: {}",
                    toolCall.toolName(), toolCall.arguments());

                toolsUsed.add(toolCall.toolName());

                // Execute via the parallel executor (handles single calls
                // efficiently too — no overhead for the common case).
                var results = parallelToolExecutor
                    .executeInParallel(List.of(toolCall));

                var toolResult = results.get(0);
                memory.addMessage(toolResult);

                if (!toolResult.success()) {
                    log.warn("Tool {} failed: {}",
                        toolCall.toolName(), toolResult.result());
                }

            } else {
                finalResponse = llmResponse.content();
                log.info("LLM produced final response after {} iteration(s) "
                    + "and {} tool call(s).",
                    iterationCount, toolsUsed.size());
                break;
            }
        }

        if (finalResponse == null) {
            log.warn("Max iterations ({}) reached for session {} without "
                + "a final response.", MAX_ITERATIONS, sessionId);
            finalResponse =
                "I apologize, but I was unable to complete this task within "
                + "the allowed number of reasoning steps. Please try "
                + "rephrasing your request or breaking it into smaller parts.";
        }

        memoryPort.save(memory);

        long durationMillis = System.currentTimeMillis() - startTime;
        log.info("Agent run completed for session {} in {}ms",
            sessionId, durationMillis);

        return new AgentResponse(
            sessionId,
            finalResponse,
            toolsUsed.size(),
            List.copyOf(toolsUsed),
            durationMillis
        );
    }
}

3.4 Structured Concurrency for Parallel Tool Execution

One of the most exciting improvements over the basic Hermes Agent is parallel tool execution using Java's Structured Concurrency. When the LLM requests multiple tool calls, we can execute them simultaneously rather than sequentially, dramatically reducing latency. Structured Concurrency makes this safe and readable — the StructuredTaskScopeensures that all subtasks complete before the scope exits, and if any subtask fails, the others are automatically cancelled.

File: openjaw-domain/src/main/java/com/openjaw/domain/service/ParallelToolExecutor.java

package com.openjaw.domain.service;

import com.openjaw.domain.model.Message.ToolResultMessage;
import com.openjaw.domain.model.ToolCall;
import com.openjaw.domain.port.out.ToolPort;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.StructuredTaskScope;

/**
 * Executes multiple tool calls in parallel using Structured Concurrency.
 *
 * When an LLM requests multiple tool calls simultaneously, executing them
 * sequentially wastes time. If each tool call takes 2 seconds, three
 * sequential calls take 6 seconds. Three parallel calls take only 2 seconds.
 *
 * Structured Concurrency (Java 21 preview feature) makes parallel execution
 * safe and readable:
 *   - All subtasks are forked within a StructuredTaskScope.
 *   - scope.join() waits for ALL subtasks to complete.
 *   - If any subtask throws, the scope cancels the others automatically.
 *   - The scope's lifetime is tied to the try-with-resources block,
 *     preventing thread leaks entirely.
 *
 * Requires: --enable-preview JVM flag (Java 21).
 */
public class ParallelToolExecutor {

    private static final Logger log =
        LoggerFactory.getLogger(ParallelToolExecutor.class);

    private final ToolPort toolPort;

    public ParallelToolExecutor(ToolPort toolPort) {
        this.toolPort = toolPort;
    }

    /**
     * Executes a list of tool calls in parallel and returns all results.
     *
     * Results are returned in the same order as the input tool calls,
     * regardless of which tool finished first.
     *
     * @param toolCalls The list of tool calls to execute in parallel.
     * @return The results of all tool executions, in the same order.
     */
    public List<ToolResultMessage> executeInParallel(List<ToolCall> toolCalls) {

        if (toolCalls.isEmpty()) {
            return List.of();
        }

        if (toolCalls.size() == 1) {
            // No Structured Concurrency overhead for a single call.
            return List.of(toolPort.executeTool(toolCalls.get(0)));
        }

        log.info("Executing {} tool calls in parallel", toolCalls.size());
        long startTime = System.currentTimeMillis();

        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

            List<StructuredTaskScope.Subtask<ToolResultMessage>> subtasks =
                new ArrayList<>(toolCalls.size());

            for (ToolCall toolCall : toolCalls) {
                var subtask = scope.fork(() -> {
                    log.debug("Parallel execution of tool: {}",
                        toolCall.toolName());
                    return toolPort.executeTool(toolCall);
                });
                subtasks.add(subtask);
            }

            // Wait for all subtasks to complete or for one to fail.
            scope.join();
            // Re-throw if any subtask failed.
            scope.throwIfFailed();

            List<ToolResultMessage> results = new ArrayList<>(subtasks.size());
            for (var subtask : subtasks) {
                results.add(subtask.get());
            }

            long duration = System.currentTimeMillis() - startTime;
            log.info("Parallel tool execution completed in {}ms for {} tools",
                duration, toolCalls.size());

            return List.copyOf(results);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(
                "Parallel tool execution was interrupted", e);
        } catch (Exception e) {
            log.error("Parallel tool execution failed: {}", e.getMessage(), e);
            throw new RuntimeException(
                "One or more tool calls failed during parallel execution", e);
        }
    }
}

CHAPTER FOUR: THE LLM ADAPTERS

4.1 The LLM Adapter Module POM

File: openjaw-adapters-llm/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.openjaw</groupId>
        <artifactId>openjaw</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>openjaw-adapters-llm</artifactId>
    <name>OpenJaw LLM Adapters</name>

    <dependencies>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-domain</artifactId>
        </dependency>

        <!-- LangChain4j core and provider integrations -->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j</artifactId>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-open-ai</artifactId>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-ollama</artifactId>
        </dependency>

        <!-- Jackson for JSON conversion of tool argument schemas -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
    </dependencies>

</project>

4.2 The LangChain4j Adapter

The key insight here is that LangChain4j's ChatLanguageModel interface is itself a kind of port — it abstracts over different LLM providers. We wrap it in our own LlmPort to keep the domain completely free of LangChain4j dependencies. This double-wrapping might seem redundant, but it is essential: if we ever want to replace LangChain4j with a different Java LLM library, we only change the adapter, not the domain.

The most complex part of this adapter is translating between the domain's message model and LangChain4j's message model, and correctly converting our domain's JSON Schema map into LangChain4j's ToolParameters format.

File: openjaw-adapters-llm/src/main/java/com/openjaw/adapters/llm/langchain4j/LangChain4jLlmAdapter.java

package com.openjaw.adapters.llm.langchain4j;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.openjaw.domain.model.AgentMemory;
import com.openjaw.domain.model.Message;
import com.openjaw.domain.model.ToolCall;
import com.openjaw.domain.model.ToolDefinition;
import com.openjaw.domain.port.out.LlmPort;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.agent.tool.ToolParameters;
import dev.langchain4j.agent.tool.ToolSpecification;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.output.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
 * LLM adapter that uses LangChain4j as the underlying communication layer.
 *
 * This adapter is model-agnostic at the LangChain4j level: it accepts any
 * ChatLanguageModel implementation. The concrete model (OpenAI, Ollama,
 * Anthropic, etc.) is injected at construction time by the bootstrap module.
 *
 * The adapter's primary responsibility is translation (the Anti-Corruption Layer):
 *   - Domain AgentMemory    -> LangChain4j List<ChatMessage>
 *   - Domain ToolDefinition -> LangChain4j ToolSpecification
 *   - LangChain4j AiMessage -> Domain Message.AssistantMessage
 *
 * Tool schema translation: LangChain4j 1.18.0 uses ToolParameters, which
 * is constructed by extracting the "required" list and "properties" map
 * from the domain's raw JSON Schema Map<String, Object>. This avoids any
 * dependency on a non-existent ToolParameters.fromJson() method.
 */
public class LangChain4jLlmAdapter implements LlmPort {

    private static final Logger log =
        LoggerFactory.getLogger(LangChain4jLlmAdapter.class);

    private final ChatLanguageModel chatModel;
    private final String providerIdentifier;
    private final ObjectMapper objectMapper;

    public LangChain4jLlmAdapter(
            ChatLanguageModel chatModel,
            String providerIdentifier) {
        this.chatModel = chatModel;
        this.providerIdentifier = providerIdentifier;
        this.objectMapper = new ObjectMapper();
    }

    @Override
    public Message.AssistantMessage chat(
            AgentMemory memory,
            List<ToolDefinition> availableTools) {

        log.debug("Calling {} with {} messages and {} tools",
            providerIdentifier, memory.size(), availableTools.size());

        List<ChatMessage> lc4jMessages = translateMessages(memory.getMessages());
        List<ToolSpecification> lc4jTools = translateTools(availableTools);

        Response<AiMessage> response;
        if (lc4jTools.isEmpty()) {
            response = chatModel.generate(lc4jMessages);
        } else {
            response = chatModel.generate(lc4jMessages, lc4jTools);
        }

        return translateResponse(response.content());
    }

    /**
     * Converts the domain's message list to LangChain4j's ChatMessage list.
     *
     * The sealed interface switch expression gives us compile-time
     * exhaustiveness checking — if we add a new message type to the domain,
     * the compiler will tell us to handle it here.
     */
    private List<ChatMessage> translateMessages(List<Message> messages) {
        List<ChatMessage> result = new ArrayList<>(messages.size());

        for (Message message : messages) {
            ChatMessage lc4jMessage = switch (message) {
                case Message.SystemMessage sm ->
                    SystemMessage.from(sm.content());

                case Message.UserMessage um ->
                    UserMessage.from(um.content());

                case Message.AssistantMessage am -> {
                    if (am.hasToolCall()) {
                        var tc = am.toolCall().get();
                        var toolRequest = ToolExecutionRequest.builder()
                            .id(tc.callId().toString())
                            .name(tc.toolName())
                            .arguments(serializeToJson(tc.arguments()))
                            .build();
                        yield AiMessage.from(toolRequest);
                    } else {
                        yield AiMessage.from(am.content());
                    }
                }

                case Message.ToolResultMessage trm ->
                    ToolExecutionResultMessage.from(
                        trm.toolCallId().toString(),
                        trm.toolName(),
                        trm.result()
                    );

                // ToolCallMessage is an internal domain record that has no
                // direct LangChain4j equivalent. The tool call information
                // is already embedded in AssistantMessage, so we skip it.
                case Message.ToolCallMessage ignored -> null;
            };

            if (lc4jMessage != null) {
                result.add(lc4jMessage);
            }
        }

        return result;
    }

    /**
     * Converts domain ToolDefinitions to LangChain4j ToolSpecifications.
     *
     * LangChain4j 1.18.0 uses ToolParameters.toolParameters(required, properties)
     * to build the parameter schema. We extract the "required" list and
     * "properties" map from the domain's raw JSON Schema Map<String, Object>.
     */
    @SuppressWarnings("unchecked")
    private List<ToolSpecification> translateTools(List<ToolDefinition> tools) {
        return tools.stream()
            .map(tool -> {
                Map<String, Object> schema = tool.parameterSchema();

                List<String> required = (List<String>)
                    schema.getOrDefault("required", Collections.emptyList());

                Map<String, Map<String, Object>> properties =
                    (Map<String, Map<String, Object>>)
                        schema.getOrDefault("properties", Collections.emptyMap());

                ToolParameters toolParameters =
                    ToolParameters.toolParameters(required, properties);

                return ToolSpecification.builder()
                    .name(tool.name())
                    .description(tool.description())
                    .parameters(toolParameters)
                    .build();
            })
            .toList();
    }

    /**
     * Converts a LangChain4j AiMessage to a domain AssistantMessage.
     */
    private Message.AssistantMessage translateResponse(AiMessage aiMessage) {
        if (aiMessage.hasToolExecutionRequests()) {
            // Take the first tool call. Most models request one at a time.
            // Parallel tool calls are supported via ParallelToolExecutor
            // when the LLM returns multiple requests.
            ToolExecutionRequest request =
                aiMessage.toolExecutionRequests().get(0);

            // The LangChain4j tool request ID may not be a valid UUID.
            // Parse safely and fall back to a new UUID if needed.
            UUID callId;
            try {
                callId = UUID.fromString(request.id());
            } catch (IllegalArgumentException e) {
                log.debug(
                    "Tool request ID '{}' is not a UUID, generating one.",
                    request.id());
                callId = UUID.randomUUID();
            }

            Map<String, Object> arguments =
                deserializeFromJson(request.arguments());

            ToolCall toolCall = new ToolCall(callId, request.name(), arguments);
            return Message.AssistantMessage.ofToolCall(toolCall);
        } else {
            return Message.AssistantMessage.ofText(aiMessage.text());
        }
    }

    private String serializeToJson(Map<String, Object> map) {
        try {
            return objectMapper.writeValueAsString(map);
        } catch (Exception e) {
            log.error("Failed to serialize map to JSON: {}", map, e);
            return "{}";
        }
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> deserializeFromJson(String json) {
        if (json == null || json.isBlank()) {
            return Map.of();
        }
        try {
            return objectMapper.readValue(json, Map.class);
        } catch (Exception e) {
            log.error("Failed to deserialize JSON to map: {}", json, e);
            return Map.of();
        }
    }

    @Override
    public String getProviderIdentifier() {
        return providerIdentifier;
    }
}

4.3 Concrete LLM Configurations

File: openjaw-adapters-llm/src/main/java/com/openjaw/adapters/llm/openai/OpenAiModelFactory.java

package com.openjaw.adapters.llm.openai;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Duration;

/**
 * Factory for creating OpenAI-backed ChatLanguageModel instances.
 *
 * Supported models as of July 2026:
 *   - gpt-5.6-sol      (flagship, best quality)
 *   - gpt-5.6-terra    (balanced quality/cost)
 *   - gpt-5.6-luna     (fastest, most cost-efficient)
 *   - gpt-5.5          (previous generation workhorse)
 *   - gpt-4o           (widely used, stable)
 */
public class OpenAiModelFactory {

    private static final Logger log =
        LoggerFactory.getLogger(OpenAiModelFactory.class);

    private OpenAiModelFactory() {}

    /**
     * Creates a ChatLanguageModel configured for GPT-5.6 Sol.
     *
     * GPT-5.6 Sol is the flagship OpenAI model as of July 2026,
     * achieving state-of-the-art results in coding and reasoning tasks.
     */
    public static ChatLanguageModel createGpt56Sol(String apiKey) {
        log.info("Creating OpenAI GPT-5.6 Sol model");
        return OpenAiChatModel.builder()
            .apiKey(apiKey)
            .modelName("gpt-5.6-sol")
            .temperature(0.3)
            .maxTokens(4096)
            .timeout(Duration.ofSeconds(90))
            .maxRetries(3)
            .build();
    }

    /**
     * Creates a ChatLanguageModel configured for GPT-5.6 Luna.
     *
     * Luna is the fastest and most cost-efficient GPT-5.6 variant.
     */
    public static ChatLanguageModel createGpt56Luna(String apiKey) {
        log.info("Creating OpenAI GPT-5.6 Luna model");
        return OpenAiChatModel.builder()
            .apiKey(apiKey)
            .modelName("gpt-5.6-luna")
            .temperature(0.2)
            .maxTokens(2048)
            .timeout(Duration.ofSeconds(30))
            .maxRetries(3)
            .build();
    }
}

File: openjaw-adapters-llm/src/main/java/com/openjaw/adapters/llm/ollama/OllamaModelFactory.java

package com.openjaw.adapters.llm.ollama;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Duration;

/**
 * Factory for creating Ollama-backed ChatLanguageModel instances.
 *
 * Ollama runs LLMs locally on your machine or on-premise servers.
 * No data is sent to external APIs. This is essential for:
 *   - Privacy-sensitive workloads (healthcare, finance, legal)
 *   - Air-gapped environments with no internet access
 *   - Cost-sensitive deployments with high request volumes
 *   - Development and testing without API costs
 *
 * Prerequisites: Ollama must be running locally (default: localhost:11434).
 * Install from https://ollama.ai and pull a model:
 *   ollama pull llama3.3:70b
 *   ollama pull mistral:7b
 */
public class OllamaModelFactory {

    private static final Logger log =
        LoggerFactory.getLogger(OllamaModelFactory.class);

    private static final String DEFAULT_OLLAMA_URL = "http://localhost:11434";

    private OllamaModelFactory() {}

    /**
     * Creates a ChatLanguageModel using Llama 3.3 70B via Ollama.
     */
    public static ChatLanguageModel createLlama33_70b(String ollamaBaseUrl) {
        String url = ollamaBaseUrl != null ? ollamaBaseUrl : DEFAULT_OLLAMA_URL;
        log.info("Creating Ollama Llama 3.3 70B model at {}", url);
        return OllamaChatModel.builder()
            .baseUrl(url)
            .modelName("llama3.3:70b")
            .temperature(0.1)
            .timeout(Duration.ofSeconds(120))
            .build();
    }

    /**
     * Creates a ChatLanguageModel using Mistral 7B via Ollama.
     *
     * Mistral 7B is a lightweight model suitable for development and
     * testing on machines without powerful GPUs.
     */
    public static ChatLanguageModel createMistral7b(String ollamaBaseUrl) {
        String url = ollamaBaseUrl != null ? ollamaBaseUrl : DEFAULT_OLLAMA_URL;
        log.info("Creating Ollama Mistral 7B model at {}", url);
        return OllamaChatModel.builder()
            .baseUrl(url)
            .modelName("mistral:7b")
            .temperature(0.2)
            .timeout(Duration.ofSeconds(60))
            .build();
    }

    /**
     * Creates a ChatLanguageModel using a custom Ollama model.
     */
    public static ChatLanguageModel createCustom(
            String ollamaBaseUrl, String modelName) {
        String url = ollamaBaseUrl != null ? ollamaBaseUrl : DEFAULT_OLLAMA_URL;
        log.info("Creating Ollama custom model '{}' at {}", modelName, url);
        return OllamaChatModel.builder()
            .baseUrl(url)
            .modelName(modelName)
            .temperature(0.2)
            .timeout(Duration.ofSeconds(120))
            .build();
    }
}

4.4 The LLM Selector

File: openjaw-adapters-llm/src/main/java/com/openjaw/adapters/llm/langchain4j/LlmAdapterSelector.java

package com.openjaw.adapters.llm.langchain4j;

import com.openjaw.adapters.llm.ollama.OllamaModelFactory;
import com.openjaw.adapters.llm.openai.OpenAiModelFactory;
import com.openjaw.domain.port.out.LlmPort;
import dev.langchain4j.model.chat.ChatLanguageModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Selects and creates the appropriate LLM adapter based on configuration.
 *
 * Configuration is read from environment variables, following the
 * 12-factor app methodology. This makes OpenJaw deployable in any
 * environment (local dev, Docker, Kubernetes) without code changes.
 *
 * Environment variables:
 *   OPENJAW_LLM_PROVIDER  - "openai" | "ollama"  (default: "openai")
 *   OPENJAW_LLM_MODEL     - Model name            (default depends on provider)
 *   OPENAI_API_KEY        - Required when provider is "openai"
 *   OLLAMA_BASE_URL       - Ollama server URL      (default: localhost:11434)
 */
public class LlmAdapterSelector {

    private static final Logger log =
        LoggerFactory.getLogger(LlmAdapterSelector.class);

    private LlmAdapterSelector() {}

    /**
     * Creates the LLM adapter based on environment variable configuration.
     *
     * @return A fully configured LlmPort implementation.
     * @throws IllegalStateException if required configuration is missing.
     */
    public static LlmPort createFromEnvironment() {
        String provider = System.getenv()
            .getOrDefault("OPENJAW_LLM_PROVIDER", "openai")
            .toLowerCase();

        String modelName = System.getenv("OPENJAW_LLM_MODEL");

        log.info("Configuring LLM provider: {}", provider);

        return switch (provider) {
            case "openai" -> createOpenAiAdapter(modelName);
            case "ollama" -> createOllamaAdapter(modelName);
            default -> throw new IllegalStateException(
                "Unknown LLM provider: '" + provider + "'. "
                + "Supported values: openai, ollama"
            );
        };
    }

    private static LlmPort createOpenAiAdapter(String modelName) {
        String apiKey = System.getenv("OPENAI_API_KEY");
        if (apiKey == null || apiKey.isBlank()) {
            throw new IllegalStateException(
                "OPENAI_API_KEY environment variable is required "
                + "when OPENJAW_LLM_PROVIDER=openai"
            );
        }

        String model = modelName != null ? modelName : "gpt-5.6-sol";

        ChatLanguageModel chatModel = switch (model) {
            case "gpt-5.6-sol"  -> OpenAiModelFactory.createGpt56Sol(apiKey);
            case "gpt-5.6-luna" -> OpenAiModelFactory.createGpt56Luna(apiKey);
            default -> {
                log.warn("Unknown OpenAI model '{}', falling back to gpt-5.6-sol",
                    model);
                yield OpenAiModelFactory.createGpt56Sol(apiKey);
            }
        };

        String identifier = "openai/" + model;
        log.info("Using LLM: {}", identifier);
        return new LangChain4jLlmAdapter(chatModel, identifier);
    }

    private static LlmPort createOllamaAdapter(String modelName) {
        String ollamaUrl = System.getenv("OLLAMA_BASE_URL");
        String model = modelName != null ? modelName : "llama3.3:70b";

        ChatLanguageModel chatModel = switch (model) {
            case "llama3.3:70b" ->
                OllamaModelFactory.createLlama33_70b(ollamaUrl);
            case "mistral:7b" ->
                OllamaModelFactory.createMistral7b(ollamaUrl);
            default ->
                OllamaModelFactory.createCustom(ollamaUrl, model);
        };

        String identifier = "ollama/" + model;
        log.info("Using LLM: {}", identifier);
        return new LangChain4jLlmAdapter(chatModel, identifier);
    }
}

CHAPTER FIVE: THE MCP TOOL ADAPTER

5.1 Understanding MCP in Depth

The Model Context Protocol is one of the most significant developments in the AI tooling space in the past two years. Before MCP, every AI application had to implement custom integrations for every tool it wanted to use. MCP solves this by defining a standard protocol for how AI applications (MCP clients) communicate with tool servers (MCP servers). An MCP server exposes a set of tools with well-defined schemas. An MCP client can discover these tools, understand their parameters, and invoke them.

The beauty of MCP is that it is completely decoupled from any specific AI framework. An MCP server written for Claude works equally well with GPT-5.6, Gemini, or any other MCP-compatible client.

A note on MCP Java SDK 0.17.0 API conventions: The SDK uses Java records extensively, so accessors follow record convention (e.g., tool.name()tool.description()result.content()result.isError()) rather than JavaBean convention (getName()getDescription()). Content items are typed — text content is represented by TextContent with a text() accessor. Tool and result construction uses plain constructors, not builders. Always cross-reference with the official SDK source at https://github.com/modelcontextprotocol/java-sdk for your exact version.

File: openjaw-adapters-mcp/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.openjaw</groupId>
        <artifactId>openjaw</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>openjaw-adapters-mcp</artifactId>
    <name>OpenJaw MCP Tool Adapters</name>

    <dependencies>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-domain</artifactId>
        </dependency>

        <!-- MCP Java SDK 0.17.0 -->
        <dependency>
            <groupId>io.modelcontextprotocol.sdk</groupId>
            <artifactId>mcp</artifactId>
        </dependency>

        <!-- Jackson for JSON processing of tool schemas -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
    </dependencies>

</project>

5.2 The MCP Tool Adapter Implementation

File: openjaw-adapters-mcp/src/main/java/com/openjaw/adapters/mcp/client/McpToolAdapter.java

package com.openjaw.adapters.mcp.client;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.openjaw.domain.model.Message.ToolResultMessage;
import com.openjaw.domain.model.ToolCall;
import com.openjaw.domain.model.ToolDefinition;
import com.openjaw.domain.port.out.ToolPort;
import io.modelcontextprotocol.sdk.McpSyncClient;
import io.modelcontextprotocol.sdk.types.CallToolRequest;
import io.modelcontextprotocol.sdk.types.CallToolResult;
import io.modelcontextprotocol.sdk.types.TextContent;
import io.modelcontextprotocol.sdk.types.Tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Tool adapter that connects to one or more MCP servers and exposes
 * their tools through the domain's ToolPort interface.
 *
 * This adapter manages a list of McpSyncClient instances, one per
 * connected MCP server. When the domain requests the list of available
 * tools, this adapter queries all connected servers and aggregates the
 * results. When the domain requests a tool execution, this adapter
 * routes the call to the correct server using a cached tool-to-server map.
 *
 * The tool-to-server routing map is built eagerly on the first call to
 * listAvailableTools() and cached for the lifetime of the adapter. This
 * avoids repeated network calls to MCP servers during tool execution.
 */
public class McpToolAdapter implements ToolPort, AutoCloseable {

    private static final Logger log =
        LoggerFactory.getLogger(McpToolAdapter.class);

    private final List<McpServerConnection> connections;
    private final ObjectMapper objectMapper;

    /**
     * Cache: maps tool name -> McpServerConnection that provides it.
     * Built on first call to listAvailableTools() and reused thereafter.
     */
    private final Map<String, McpServerConnection> toolServerCache;

    public McpToolAdapter(List<McpServerConnection> connections) {
        this.connections = List.copyOf(connections);
        this.objectMapper = new ObjectMapper();
        this.toolServerCache = new HashMap<>();
        log.info("MCP Tool Adapter initialized with {} server(s)",
            connections.size());
    }

    /**
     * Queries all connected MCP servers and returns the aggregated list
     * of available tools. Also populates the tool-to-server routing cache.
     *
     * If a server is unavailable, its tools are omitted from the result.
     */
    @Override
    public List<ToolDefinition> listAvailableTools() {
        List<ToolDefinition> allTools = new ArrayList<>();
        toolServerCache.clear();

        for (McpServerConnection connection : connections) {
            try {
                // MCP SDK 0.17.0: ListToolsResult uses record accessor tools()
                List<Tool> serverTools =
                    connection.client().listTools().tools();

                for (Tool tool : serverTools) {
                    ToolDefinition definition =
                        translateToolDefinition(tool, connection.serverName());
                    allTools.add(definition);
                    // Cache the server that provides this tool.
                    toolServerCache.put(tool.name(), connection);
                }

                log.debug("Server '{}' provides {} tool(s)",
                    connection.serverName(), serverTools.size());

            } catch (Exception e) {
                log.warn("Failed to list tools from MCP server '{}': {}",
                    connection.serverName(), e.getMessage());
            }
        }

        log.debug("Total available tools across all MCP servers: {}",
            allTools.size());
        return List.copyOf(allTools);
    }

    /**
     * Executes a tool call by routing it to the appropriate MCP server
     * using the cached tool-to-server map.
     */
    @Override
    public ToolResultMessage executeTool(ToolCall toolCall) {
        log.info("Executing tool '{}' with args: {}",
            toolCall.toolName(), toolCall.arguments());

        McpServerConnection targetConnection =
            toolServerCache.get(toolCall.toolName());

        if (targetConnection == null) {
            log.error("No MCP server found for tool '{}'", toolCall.toolName());
            return ToolResultMessage.failure(
                toolCall.callId(),
                toolCall.toolName(),
                "Tool '" + toolCall.toolName() + "' is not available. "
                + "The tool may have been removed or the server may be offline."
            );
        }

        try {
            // MCP SDK 0.17.0: CallToolRequest(name, arguments)
            CallToolRequest request = new CallToolRequest(
                toolCall.toolName(),
                toolCall.arguments()
            );

            // MCP SDK 0.17.0: result uses record accessors content() / isError()
            CallToolResult result =
                targetConnection.client().callTool(request);

            String resultText = extractTextContent(result);

            if (Boolean.TRUE.equals(result.isError())) {
                log.warn("Tool '{}' returned an error: {}",
                    toolCall.toolName(), resultText);
                return ToolResultMessage.failure(
                    toolCall.callId(), toolCall.toolName(), resultText
                );
            }

            log.debug("Tool '{}' succeeded, result length: {} chars",
                toolCall.toolName(), resultText.length());
            return ToolResultMessage.success(
                toolCall.callId(), toolCall.toolName(), resultText
            );

        } catch (Exception e) {
            log.error("Exception executing tool '{}': {}",
                toolCall.toolName(), e.getMessage(), e);
            return ToolResultMessage.failure(
                toolCall.callId(),
                toolCall.toolName(),
                "Tool execution failed with error: " + e.getMessage()
            );
        }
    }

    /**
     * Translates an MCP Tool to a domain ToolDefinition.
     * MCP SDK 0.17.0: Tool uses record accessors name(), description(),
     * inputSchema().
     */
    private ToolDefinition translateToolDefinition(
            Tool mcpTool, String serverName) {
        Map<String, Object> schema = convertSchema(mcpTool.inputSchema());
        return new ToolDefinition(
            mcpTool.name(),
            mcpTool.description() + " [via " + serverName + "]",
            schema
        );
    }

    /**
     * Converts an MCP input schema object to a Map<String, Object>.
     * The MCP SDK represents input schemas as Map<String, Object> already;
     * we round-trip through Jackson to ensure a clean, deep-copied map.
     */
    @SuppressWarnings("unchecked")
    private Map<String, Object> convertSchema(Object inputSchema) {
        if (inputSchema == null) {
            return Map.of("type", "object", "properties", Map.of());
        }
        try {
            String json = objectMapper.writeValueAsString(inputSchema);
            return objectMapper.readValue(json, Map.class);
        } catch (Exception e) {
            log.warn("Failed to convert MCP tool schema: {}", e.getMessage());
            return Map.of("type", "object", "properties", Map.of());
        }
    }

    /**
     * Extracts all text content from an MCP CallToolResult.
     *
     * MCP SDK 0.17.0: content items are typed. TextContent has a text()
     * record accessor. We filter for TextContent instances specifically.
     */
    private String extractTextContent(CallToolResult result) {
        if (result.content() == null || result.content().isEmpty()) {
            return "(empty result)";
        }

        StringBuilder sb = new StringBuilder();
        for (var item : result.content()) {
            // MCP SDK 0.17.0: content items are typed objects.
            // TextContent is the most common type for tool results.
            if (item instanceof TextContent textContent) {
                String text = textContent.text();
                if (text != null && !text.isBlank()) {
                    if (!sb.isEmpty()) {
                        sb.append("\n");
                    }
                    sb.append(text);
                }
            }
        }

        return sb.isEmpty() ? "(empty result)" : sb.toString();
    }

    @Override
    public void close() {
        log.info("Closing {} MCP server connection(s)", connections.size());
        for (McpServerConnection connection : connections) {
            try {
                connection.client().close();
                log.debug("Closed connection to MCP server '{}'",
                    connection.serverName());
            } catch (Exception e) {
                log.warn("Error closing connection to MCP server '{}': {}",
                    connection.serverName(), e.getMessage());
            }
        }
    }

    /**
     * Represents a named connection to an MCP server.
     */
    public record McpServerConnection(
            String serverName,
            McpSyncClient client
    ) {}
}

5.3 The MCP Connection Factory

File: openjaw-adapters-mcp/src/main/java/com/openjaw/adapters/mcp/client/McpConnectionFactory.java

package com.openjaw.adapters.mcp.client;

import com.openjaw.adapters.mcp.client.McpToolAdapter.McpServerConnection;
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.McpSyncClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.sdk.client.transport.ServerParameters;
import io.modelcontextprotocol.sdk.client.transport.StdioClientTransport;
import io.modelcontextprotocol.sdk.types.ClientCapabilities;
import io.modelcontextprotocol.sdk.types.Implementation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Duration;
import java.util.List;

/**
 * Factory for creating MCP server connections.
 *
 * Supports two transport mechanisms:
 *
 * STDIO Transport: The MCP server runs as a child process of OpenJaw.
 * Communication happens via the process's standard input and output streams.
 * This is the simplest and most common setup for local development.
 * The MCP SDK 0.17.0 uses ServerParameters to configure the subprocess.
 *
 * HTTP SSE Transport: The MCP server runs as a separate HTTP service.
 * Communication happens via HTTP Server-Sent Events. Appropriate for
 * remote servers or containerized deployments.
 */
public class McpConnectionFactory {

    private static final Logger log =
        LoggerFactory.getLogger(McpConnectionFactory.class);

    private static final Implementation CLIENT_INFO =
        new Implementation("openjaw", "1.0.0");

    private McpConnectionFactory() {}

    /**
     * Creates an MCP connection using STDIO transport.
     *
     * MCP SDK 0.17.0 uses ServerParameters to describe the subprocess.
     * The first element of the command list is the executable; the rest
     * are arguments.
     *
     * @param serverName A human-readable name for this server (for logging).
     * @param command    The executable to launch (e.g., "java", "npx").
     * @param args       Arguments to pass to the executable.
     * @return A McpServerConnection ready for use.
     */
    public static McpServerConnection createStdioConnection(
            String serverName,
            String command,
            List<String> args) {

        log.info("Creating STDIO MCP connection to '{}': {} {}",
            serverName, command, args);

        // MCP SDK 0.17.0: ServerParameters describes the subprocess.
        // The environment is inherited from the current process by default,
        // so BRAVE_API_KEY and other env vars are automatically available
        // to the subprocess.
        ServerParameters serverParams = ServerParameters.builder(command)
            .args(args)
            .build();

        StdioClientTransport transport = new StdioClientTransport(serverParams);

        McpSyncClient client = McpClient.sync(transport)
            .clientInfo(CLIENT_INFO)
            .capabilities(ClientCapabilities.builder().build())
            .requestTimeout(Duration.ofSeconds(30))
            .build();

        client.initialize();
        log.info("STDIO MCP connection to '{}' established successfully",
            serverName);

        return new McpServerConnection(serverName, client);
    }

    /**
     * Creates an MCP connection using HTTP SSE transport.
     *
     * @param serverName A human-readable name for this server.
     * @param serverUrl  The base URL of the MCP server
     *                   (e.g., "http://localhost:8080").
     * @return A McpServerConnection ready for use.
     */
    public static McpServerConnection createHttpSseConnection(
            String serverName,
            String serverUrl) {

        log.info("Creating HTTP SSE MCP connection to '{}' at {}",
            serverName, serverUrl);

        HttpClientSseClientTransport transport =
            HttpClientSseClientTransport.builder(serverUrl).build();

        McpSyncClient client = McpClient.sync(transport)
            .clientInfo(CLIENT_INFO)
            .capabilities(ClientCapabilities.builder().build())
            .requestTimeout(Duration.ofSeconds(30))
            .build();

        client.initialize();
        log.info("HTTP SSE MCP connection to '{}' established successfully",
            serverName);

        return new McpServerConnection(serverName, client);
    }
}

5.4 Building a Custom MCP Tool Server

Here is where things get really interesting. While there are many pre-built MCP servers available (for web search, file system access, GitHub, databases, etc.), you will often want to build your own. Let us build a simple but genuinely useful web search MCP server that OpenJaw can use.

This server exposes a single tool called web_search that takes a query string and returns search results from the Brave Search API.

Deployment note: The WebSearchMcpServer runs as a separate standalone process from the main OpenJaw application. It communicates with OpenJaw via STDIO (standard input/output). OpenJaw launches it as a subprocess using McpConnectionFactory.createStdioConnection(). The BRAVE_API_KEYenvironment variable is inherited by the subprocess automatically — the OS handles environment inheritance for child processes.

MCP SDK 0.17.0 Server API: The server is built with McpServer.sync(transport), configured via a builder, and started by calling server.awaitClose() which blocks the main thread until the STDIO stream closes (i.e., until OpenJaw shuts down). Tool construction uses new Tool(name, description, inputSchemaMap). Result construction uses new CallToolResult(contentList, isError). Text content uses new TextContent(text).

File: openjaw-adapters-mcp/src/main/java/com/openjaw/adapters/mcp/server/WebSearchMcpServer.java

package com.openjaw.adapters.mcp.server;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.sdk.McpServer;
import io.modelcontextprotocol.sdk.McpSyncServer;
import io.modelcontextprotocol.sdk.server.transport.StdioServerTransport;
import io.modelcontextprotocol.sdk.types.CallToolResult;
import io.modelcontextprotocol.sdk.types.Implementation;
import io.modelcontextprotocol.sdk.types.ServerCapabilities;
import io.modelcontextprotocol.sdk.types.TextContent;
import io.modelcontextprotocol.sdk.types.Tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;

/**
 * A standalone MCP server that exposes a web search tool.
 *
 * This server runs as a separate process and communicates with
 * OpenJaw (or any other MCP client) via STDIO. It exposes one
 * tool: web_search, which queries the Brave Search API and returns
 * formatted results.
 *
 * To run this server standalone (for testing):
 *   export BRAVE_API_KEY=your-key-here
 *   java --enable-preview -cp openjaw-bootstrap-1.0.0-SNAPSHOT.jar \
 *        com.openjaw.adapters.mcp.server.WebSearchMcpServer
 *
 * In normal operation, OpenJaw launches this server automatically
 * as a subprocess via the STDIO MCP transport. The BRAVE_API_KEY
 * environment variable is inherited from the parent process.
 */
public class WebSearchMcpServer {

    private static final Logger log =
        LoggerFactory.getLogger(WebSearchMcpServer.class);

    private static final String BRAVE_SEARCH_URL =
        "https://api.search.brave.com/res/v1/web/search";

    private final String braveApiKey;
    private final HttpClient httpClient;
    private final ObjectMapper objectMapper;

    public WebSearchMcpServer(String braveApiKey) {
        this.braveApiKey = braveApiKey;
        this.httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
        this.objectMapper = new ObjectMapper();
    }

    /**
     * Starts the MCP server and blocks until the STDIO stream closes.
     *
     * MCP SDK 0.17.0: Tool is constructed with new Tool(name, description,
     * inputSchemaMap). CallToolResult with new CallToolResult(content, isError).
     * TextContent with new TextContent(text). The server blocks via
     * server.awaitClose() until the parent process (OpenJaw) shuts down.
     */
    public void start() {
        log.info("Starting OpenJaw Web Search MCP Server");

        // MCP SDK 0.17.0: Tool constructor takes (name, description, inputSchema).
        // inputSchema is a Map<String, Object> representing a JSON Schema.
        Tool webSearchTool = new Tool(
            "web_search",
            "Searches the web for current information using the Brave "
            + "Search API. Use this tool when you need up-to-date "
            + "information that may not be in your training data, such as "
            + "recent news, current prices, live sports scores, or any "
            + "information that changes frequently. Returns a list of "
            + "relevant web pages with titles, URLs, and snippets.",
            Map.of(
                "type", "object",
                "properties", Map.of(
                    "query", Map.of(
                        "type", "string",
                        "description",
                            "The search query. Be specific and use keywords "
                            + "that will find the most relevant results."
                    ),
                    "count", Map.of(
                        "type", "integer",
                        "description",
                            "Number of results to return (1-10, default 5).",
                        "default", 5
                    )
                ),
                "required", List.of("query")
            )
        );

        // MCP SDK 0.17.0: McpServer.sync(transport) returns a builder.
        // .serverInfo() sets the server's identity.
        // .capabilities() declares what the server supports.
        // .tool() registers a tool with its handler.
        // .build() creates the server and starts it.
        McpSyncServer server = McpServer.sync(new StdioServerTransport())
            .serverInfo(new Implementation("openjaw-web-search", "1.0.0"))
            .capabilities(ServerCapabilities.builder()
                .tools(true)
                .build())
            .tool(webSearchTool, (exchange, arguments) -> {
                String query = (String) arguments.get("query");
                int count = arguments.containsKey("count")
                    ? ((Number) arguments.get("count")).intValue()
                    : 5;

                log.info("Executing web_search: query='{}', count={}",
                    query, count);

                return executeWebSearch(query, count);
            })
            .build();

        log.info("OpenJaw Web Search MCP Server started, "
            + "waiting for requests...");

        // Block the main thread until the STDIO stream closes.
        // This happens when OpenJaw (the parent process) shuts down.
        server.awaitClose();
    }

    private CallToolResult executeWebSearch(String query, int count) {
        try {
            String encodedQuery =
                URLEncoder.encode(query, StandardCharsets.UTF_8);
            String url = BRAVE_SEARCH_URL
                + "?q=" + encodedQuery
                + "&count=" + count
                + "&text_decorations=false";

            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Accept", "application/json")
                .header("Accept-Encoding", "gzip")
                .header("X-Subscription-Token", braveApiKey)
                .timeout(Duration.ofSeconds(15))
                .GET()
                .build();

            HttpResponse<String> response = httpClient.send(
                request, HttpResponse.BodyHandlers.ofString()
            );

            if (response.statusCode() != 200) {
                String errorMsg = "Brave Search API returned status "
                    + response.statusCode() + ": " + response.body();
                log.error(errorMsg);
                // MCP SDK 0.17.0: new CallToolResult(content, isError)
                return new CallToolResult(
                    List.of(new TextContent(errorMsg)), true
                );
            }

            String formattedResults =
                formatSearchResults(response.body(), query);

            return new CallToolResult(
                List.of(new TextContent(formattedResults)), false
            );

        } catch (Exception e) {
            log.error("Web search failed for query '{}': {}",
                query, e.getMessage(), e);
            return new CallToolResult(
                List.of(new TextContent(
                    "Web search failed: " + e.getMessage()
                )),
                true
            );
        }
    }

    @SuppressWarnings("unchecked")
    private String formatSearchResults(String jsonResponse, String query) {
        try {
            Map<String, Object> parsed =
                objectMapper.readValue(jsonResponse, Map.class);

            Map<String, Object> web =
                (Map<String, Object>) parsed.get("web");
            if (web == null) {
                return "No web results found for: " + query;
            }

            List<Map<String, Object>> results =
                (List<Map<String, Object>>) web.get("results");
            if (results == null || results.isEmpty()) {
                return "No results found for: " + query;
            }

            StringBuilder sb = new StringBuilder();
            sb.append("Web search results for: ").append(query).append("\n\n");

            for (int i = 0; i < results.size(); i++) {
                Map<String, Object> result = results.get(i);
                sb.append(i + 1).append(". ");
                sb.append(result.getOrDefault("title", "No title"))
                  .append("\n");
                sb.append("   URL: ")
                  .append(result.getOrDefault("url", ""))
                  .append("\n");
                sb.append("   ")
                  .append(result.getOrDefault("description", ""))
                  .append("\n\n");
            }

            return sb.toString();

        } catch (Exception e) {
            log.error("Failed to parse search results: {}", e.getMessage());
            return "Failed to parse search results: " + e.getMessage();
        }
    }

    /**
     * Entry point for running the MCP server as a standalone process.
     * BRAVE_API_KEY is read from the environment — inherited from the
     * parent process when launched by OpenJaw, or set manually for
     * standalone testing.
     */
    public static void main(String[] args) {
        String braveApiKey = System.getenv("BRAVE_API_KEY");
        if (braveApiKey == null || braveApiKey.isBlank()) {
            System.err.println(
                "ERROR: BRAVE_API_KEY environment variable is required");
            System.exit(1);
        }
        new WebSearchMcpServer(braveApiKey).start();
    }
}

CHAPTER SIX: THE MEMORY ADAPTER

6.1 Persistent Memory with File System Storage

The memory adapter gives OpenJaw the ability to remember conversations across sessions. Each session is stored as a JSON file in a configurable directory. This is not the most scalable solution for a multi-user deployment (where you would want a database), but it is perfect for a single-user or small-team deployment and is completely self-contained with no external dependencies.

The serialization of our sealed Message interface hierarchy requires careful Jackson configuration. Three things must work together:

  1. The -parameters compiler flag (set in the parent POM) preserves parameter names in bytecode.
  2. jackson-module-parameter-names uses those preserved names to deserialize records without @JsonCreator.
  3. jackson-datatype-jdk8 handles Optional<ToolCall> in AssistantMessage.
  4. A Jackson mixin on Message.class adds a @type discriminator field so Jackson knows which concrete record type to instantiate.

File: openjaw-adapters-memory/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.openjaw</groupId>
        <artifactId>openjaw</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>openjaw-adapters-memory</artifactId>
    <name>OpenJaw Memory Adapters</name>

    <dependencies>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-domain</artifactId>
        </dependency>

        <!-- Jackson core -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <!-- Java 8 date/time (Instant) support -->
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
        </dependency>

        <!--
            Optional<ToolCall> support in AssistantMessage.
            Without this, Jackson cannot serialize/deserialize Optional fields.
        -->
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jdk8</artifactId>
        </dependency>

        <!--
            Record deserialization without @JsonCreator.
            Works together with the -parameters compiler flag in the parent POM.
        -->
        <dependency>
            <groupId>com.fasterxml.jackson.module</groupId>
            <artifactId>jackson-module-parameter-names</artifactId>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
    </dependencies>

</project>

File: openjaw-adapters-memory/src/main/java/com/openjaw/adapters/memory/filesystem/FileSystemMemoryAdapter.java

package com.openjaw.adapters.memory.filesystem;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import com.openjaw.domain.model.AgentMemory;
import com.openjaw.domain.model.Message;
import com.openjaw.domain.port.out.MemoryPort;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
 * Memory adapter that persists conversation history to the file system.
 *
 * Each session is stored as a single JSON file named by session ID:
 *   {storageDir}/{sessionId}.json
 *
 * The JSON format is human-readable and can be inspected, edited, or
 * backed up using standard file tools. Writes use an atomic
 * write-then-rename strategy to prevent file corruption on crash.
 *
 * Jackson configuration for Java records:
 *   - ParameterNamesModule + -parameters compiler flag: enables record
 *     deserialization without @JsonCreator annotations.
 *   - Jdk8Module: handles Optional<ToolCall> in AssistantMessage.
 *   - JavaTimeModule: handles Instant timestamps.
 *   - MessageMixin: adds @type discriminator for the sealed Message hierarchy.
 *   - USE_ANNOTATIONS_FOR_CREATOR_INFERENCE: helps Jackson find the
 *     canonical record constructor automatically.
 */
public class FileSystemMemoryAdapter implements MemoryPort {

    private static final Logger log =
        LoggerFactory.getLogger(FileSystemMemoryAdapter.class);

    private static final int DEFAULT_MAX_MESSAGES = 100;

    private final Path storageDir;
    private final ObjectMapper objectMapper;
    private final int maxMessages;

    public FileSystemMemoryAdapter(Path storageDir, int maxMessages) {
        this.storageDir = storageDir;
        this.maxMessages = maxMessages;
        this.objectMapper = createObjectMapper();

        try {
            Files.createDirectories(storageDir);
            log.info("Memory storage directory: {}",
                storageDir.toAbsolutePath());
        } catch (IOException e) {
            throw new RuntimeException(
                "Failed to create memory storage directory: " + storageDir, e
            );
        }
    }

    public FileSystemMemoryAdapter(Path storageDir) {
        this(storageDir, DEFAULT_MAX_MESSAGES);
    }

    @Override
    public void save(AgentMemory memory) {
        Path sessionFile = sessionFilePath(memory.getSessionId());
        Path tempFile = sessionFile.resolveSibling(
            memory.getSessionId() + ".tmp"
        );

        try {
            MemorySnapshot snapshot = MemorySnapshot.from(memory);
            objectMapper.writeValue(tempFile.toFile(), snapshot);

            // Atomic rename: prevents partial writes from corrupting the file.
            Files.move(tempFile, sessionFile,
                StandardCopyOption.REPLACE_EXISTING,
                StandardCopyOption.ATOMIC_MOVE
            );

            log.debug("Saved session {} ({} messages) to {}",
                memory.getSessionId(), memory.size(), sessionFile);

        } catch (IOException e) {
            log.error("Failed to save session {}: {}",
                memory.getSessionId(), e.getMessage(), e);
            try {
                Files.deleteIfExists(tempFile);
            } catch (IOException ignored) {}
            throw new RuntimeException(
                "Failed to persist agent memory for session: "
                + memory.getSessionId(), e
            );
        }
    }

    @Override
    public Optional<AgentMemory> load(UUID sessionId) {
        Path sessionFile = sessionFilePath(sessionId);

        if (!Files.exists(sessionFile)) {
            log.debug("No existing session file for {}", sessionId);
            return Optional.empty();
        }

        try {
            MemorySnapshot snapshot =
                objectMapper.readValue(sessionFile.toFile(),
                    MemorySnapshot.class);

            AgentMemory memory = snapshot.toAgentMemory(maxMessages);
            log.info("Loaded session {} with {} messages",
                sessionId, memory.size());
            return Optional.of(memory);

        } catch (IOException e) {
            log.error("Failed to load session {}: {}",
                sessionId, e.getMessage(), e);
            // Return empty rather than crashing — a corrupted session file
            // should not prevent the agent from starting a fresh session.
            return Optional.empty();
        }
    }

    @Override
    public AgentMemory createNew(UUID sessionId) {
        log.debug("Creating new session: {}", sessionId);
        return new AgentMemory(sessionId, maxMessages);
    }

    private Path sessionFilePath(UUID sessionId) {
        return storageDir.resolve(sessionId + ".json");
    }

    /**
     * Creates and configures the Jackson ObjectMapper for memory serialization.
     *
     * The combination of ParameterNamesModule + -parameters compiler flag
     * allows Jackson to deserialize Java records by matching JSON field names
     * to record component names, without requiring @JsonCreator annotations.
     */
    private ObjectMapper createObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();

        // Register modules for Java 8+ types.
        mapper.registerModule(new JavaTimeModule());   // Instant support
        mapper.registerModule(new Jdk8Module());       // Optional support
        mapper.registerModule(new ParameterNamesModule()); // Record support

        // Serialize Instant as ISO-8601 string, not a numeric timestamp.
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        // Pretty-print JSON for human readability and debuggability.
        mapper.enable(SerializationFeature.INDENT_OUTPUT);

        // Help Jackson find the canonical record constructor automatically.
        mapper.enable(MapperFeature.USE_ANNOTATIONS_FOR_CREATOR_INFERENCE);

        // Register the mixin that adds @type discriminator to Message records.
        mapper.addMixIn(Message.class, MessageMixin.class);

        return mapper;
    }

    /**
     * Jackson mixin for the sealed Message interface.
     *
     * The @JsonTypeInfo annotation instructs Jackson to include a "@type"
     * field in the JSON output that identifies the concrete type of each
     * message. The @JsonSubTypes annotation maps type name strings to the
     * concrete record classes.
     *
     * Every permitted type in the sealed interface must be listed here.
     * The compiler will not catch omissions — be careful when adding new
     * message types to the domain.
     */
    @JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        property = "@type"
    )
    @JsonSubTypes({
        @JsonSubTypes.Type(value = Message.UserMessage.class,
            name = "user"),
        @JsonSubTypes.Type(value = Message.AssistantMessage.class,
            name = "assistant"),
        @JsonSubTypes.Type(value = Message.ToolCallMessage.class,
            name = "tool_call"),
        @JsonSubTypes.Type(value = Message.ToolResultMessage.class,
            name = "tool_result"),
        @JsonSubTypes.Type(value = Message.SystemMessage.class,
            name = "system")
    })
    private abstract static class MessageMixin {}

    /**
     * A serialization-friendly snapshot of AgentMemory.
     *
     * We use a separate DTO for serialization rather than serializing
     * AgentMemory directly, to decouple the domain model from the
     * serialization format. If we change the domain model, we only
     * need to update this snapshot class and its conversion methods.
     */
    public record MemorySnapshot(
            UUID sessionId,
            List<Message> messages
    ) {
        public static MemorySnapshot from(AgentMemory memory) {
            return new MemorySnapshot(
                memory.getSessionId(),
                new ArrayList<>(memory.getMessages())
            );
        }

        public AgentMemory toAgentMemory(int maxMessages) {
            AgentMemory memory = new AgentMemory(sessionId, maxMessages);
            messages.forEach(memory::addMessage);
            return memory;
        }
    }
}

CHAPTER SEVEN: THE CLI ADAPTER

7.1 The CLI Module POM

File: openjaw-adapters-ui/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.openjaw</groupId>
        <artifactId>openjaw</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>openjaw-adapters-ui</artifactId>
    <name>OpenJaw UI Adapters</name>

    <dependencies>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-domain</artifactId>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
    </dependencies>

</project>

7.2 The Command Line Interface Adapter

The CLI adapter is the driving adapter that allows humans to interact with OpenJaw from the terminal. It is intentionally simple — its only job is to read user input, call the RunAgentUseCase, and display the response. All the intelligence is in the domain.

The CLI adapter demonstrates how Virtual Threads make concurrent I/O trivially easy. We submit the agent's reasoning cycle to a virtual thread executor, which means the main thread can display a "thinking" animation while the agent works. Virtual threads are extremely cheap to create (unlike platform threads), so we create a new one for each agent task rather than pooling them. This is the correct and idiomatic approach for Virtual Threads in Java 21.

Note that the console appender in logback.xml writes to System.err rather than System.out. This is intentional: it keeps log messages separate from the agent's responses, which go to System.out. Users who want clean output can redirect stderr to a log file.

File: openjaw-adapters-ui/src/main/java/com/openjaw/adapters/ui/cli/CliAdapter.java

package com.openjaw.adapters.ui.cli;

import com.openjaw.domain.port.in.RunAgentUseCase;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

/**
 * Command-line interface adapter for OpenJaw.
 *
 * This adapter provides a simple REPL (Read-Eval-Print Loop) interface
 * for interacting with the agent from the terminal. It is the "driving"
 * or "primary" adapter in hexagonal architecture terms: it drives the
 * application by calling the input port (RunAgentUseCase).
 *
 * Virtual Threads (Java 21): each agent task runs on its own virtual thread.
 * Virtual threads are extremely cheap — we create a new one per task rather
 * than pooling them. The main thread stays responsive (for Ctrl+C signals)
 * while the agent thinks.
 */
public class CliAdapter {

    private static final Logger log =
        LoggerFactory.getLogger(CliAdapter.class);

    private static final String BANNER = """
        ╔══════════════════════════════════════════════════════════╗
        ║           OPENJAW Agentic AI Platform v1.0               ║
        ║           Built with Java 21 + LangChain4j 1.18.0        ║
        ║           MCP Tool Protocol v0.17.0                       ║
        ╚══════════════════════════════════════════════════════════╝
        Type your message and press Enter. Type 'exit' to quit.
        Type 'new' to start a fresh session.
        """;

    private final RunAgentUseCase agentUseCase;

    public CliAdapter(RunAgentUseCase agentUseCase) {
        this.agentUseCase = agentUseCase;
    }

    /**
     * Starts the CLI REPL loop.
     *
     * Blocks until the user types 'exit' or the process is interrupted.
     * Maintains a single session ID across all interactions, providing
     * continuity of conversation.
     */
    public void start() {
        System.out.println(BANNER);

        UUID sessionId = UUID.randomUUID();
        System.out.println("Session ID: " + sessionId);
        System.out.println(
            "(Memory will be saved and can be resumed later)\n");

        ExecutorService virtualThreadExecutor =
            Executors.newVirtualThreadPerTaskExecutor();

        try (BufferedReader reader =
                new BufferedReader(new InputStreamReader(System.in))) {

            String line;
            while (true) {
                System.out.print("\nYou: ");
                System.out.flush();

                line = reader.readLine();
                if (line == null || "exit".equalsIgnoreCase(line.trim())) {
                    System.out.println(
                        "\nGoodbye! Your session has been saved.");
                    break;
                }

                if ("new".equalsIgnoreCase(line.trim())) {
                    sessionId = UUID.randomUUID();
                    System.out.println("\nStarted new session: " + sessionId);
                    continue;
                }

                if (line.isBlank()) {
                    continue;
                }

                final String userInput = line.trim();
                final UUID currentSessionId = sessionId;

                Future<RunAgentUseCase.AgentResponse> future =
                    virtualThreadExecutor.submit(() ->
                        agentUseCase.run(currentSessionId, userInput)
                    );

                try {
                    System.out.print("\nOpenJaw: [thinking");
                    System.out.flush();

                    while (!future.isDone()) {
                        Thread.sleep(500);
                        System.out.print(".");
                        System.out.flush();
                    }

                    RunAgentUseCase.AgentResponse response = future.get();

                    System.out.println("]\n");
                    System.out.println("OpenJaw: " + response.responseText());

                    if (response.toolCallCount() > 0) {
                        System.out.printf(
                            "\n[Used %d tool(s): %s | %.1f seconds]\n",
                            response.toolCallCount(),
                            String.join(", ", response.toolsUsed()),
                            response.durationMillis() / 1000.0
                        );
                    } else {
                        System.out.printf(
                            "\n[%.1f seconds]\n",
                            response.durationMillis() / 1000.0
                        );
                    }

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.err.println("\nInterrupted.");
                } catch (Exception e) {
                    System.out.println("]\n");
                    System.err.println("Error: " + e.getMessage());
                    log.error("Agent run failed", e);
                }
            }

        } catch (Exception e) {
            log.error("CLI adapter error", e);
        } finally {
            virtualThreadExecutor.shutdown();
        }
    }
}

CHAPTER EIGHT: THE BOOTSTRAP MODULE — WIRING IT ALL TOGETHER

8.1 The Bootstrap POM

File: openjaw-bootstrap/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.openjaw</groupId>
        <artifactId>openjaw</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>openjaw-bootstrap</artifactId>
    <name>OpenJaw Bootstrap</name>

    <dependencies>
        <!-- All modules pulled in here for wiring -->
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-domain</artifactId>
        </dependency>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-adapters-llm</artifactId>
        </dependency>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-adapters-mcp</artifactId>
        </dependency>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-adapters-memory</artifactId>
        </dependency>
        <dependency>
            <groupId>com.openjaw</groupId>
            <artifactId>openjaw-adapters-ui</artifactId>
        </dependency>

        <!--
            Logging implementation lives ONLY in bootstrap, not in
            individual modules. Modules declare only slf4j-api.
            This prevents logging implementation conflicts.
        -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!--
                Maven Shade Plugin: creates a single executable fat JAR
                with all dependencies bundled. This is the artifact you
                deploy and run.
            -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.6.0</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <createDependencyReducedPom>false</createDependencyReducedPom>
                            <transformers>
                                <transformer implementation=
                                    "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>
                                        com.openjaw.OpenJawApplication
                                    </mainClass>
                                </transformer>
                                <!--
                                    ServicesResourceTransformer merges
                                    META-INF/services files from all JARs.
                                    Required for Jackson, SLF4J, and MCP SDK
                                    service loader entries to work correctly
                                    in the shaded JAR.
                                -->
                                <transformer implementation=
                                    "org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

8.2 The Logback Configuration

File: openjaw-bootstrap/src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!--
        Console appender writes to System.err (not System.out).
        This keeps log messages separate from the agent's responses,
        which go to System.out. Users can redirect stderr to a log file:
            java --enable-preview -jar openjaw-bootstrap.jar 2>openjaw.log
    -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <target>System.err</target>
        <encoder>
            <pattern>
                %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
            </pattern>
        </encoder>
    </appender>

    <!--
        Rolling file appender: writes logs to a file that rolls daily,
        capped at 100 MB per file, keeping 30 days of history.
    -->
    <appender name="FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${user.home}/.openjaw/logs/openjaw.log</file>
        <rollingPolicy
            class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>
                ${user.home}/.openjaw/logs/openjaw.%d{yyyy-MM-dd}.log
            </fileNamePattern>
            <maxHistory>30</maxHistory>
            <totalSizeCap>1GB</totalSizeCap>
        </rollingPolicy>
        <encoder>
            <pattern>
                %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level
                %logger{50} - %msg%n
            </pattern>
        </encoder>
    </appender>

    <!-- Reduce noise from verbose third-party libraries -->
    <logger name="dev.langchain4j"          level="WARN"/>
    <logger name="io.modelcontextprotocol"  level="WARN"/>
    <logger name="org.apache.http"          level="WARN"/>

    <!-- OpenJaw application logs at DEBUG level for full visibility -->
    <logger name="com.openjaw" level="DEBUG"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

</configuration>

8.3 Dependency Injection Without a Framework

The bootstrap module is where all the pieces come together. It is responsible for reading configuration, creating all the adapter instances, wiring them to the domain ports, and starting the application. We do this without a dependency injection framework like Spring — pure constructor injection is sufficient for our needs and keeps the application startup fast and transparent.

This is the Composition Root pattern: there is exactly one place in the application where all dependencies are assembled. Everything else uses constructor injection and never creates its own dependencies.

File: openjaw-bootstrap/src/main/java/com/openjaw/OpenJawApplication.java

package com.openjaw;

import com.openjaw.adapters.llm.langchain4j.LlmAdapterSelector;
import com.openjaw.adapters.mcp.client.McpConnectionFactory;
import com.openjaw.adapters.mcp.client.McpToolAdapter;
import com.openjaw.adapters.mcp.client.McpToolAdapter.McpServerConnection;
import com.openjaw.adapters.memory.filesystem.FileSystemMemoryAdapter;
import com.openjaw.adapters.ui.cli.CliAdapter;
import com.openjaw.domain.port.in.RunAgentUseCase;
import com.openjaw.domain.port.out.LlmPort;
import com.openjaw.domain.port.out.MemoryPort;
import com.openjaw.domain.port.out.ToolPort;
import com.openjaw.domain.service.AgentOrchestrator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * The Composition Root of OpenJaw.
 *
 * This is the ONLY place in the entire application where concrete
 * implementations are instantiated and wired together. Every other
 * class receives its dependencies through constructor injection and
 * works only with interfaces (ports), never with concrete classes.
 *
 * This design makes the application:
 *   - Testable: swap any adapter with a mock in tests
 *   - Flexible: change providers by changing this file only
 *   - Transparent: the entire wiring is visible in one place
 *
 * Environment variables:
 *   OPENJAW_LLM_PROVIDER   - "openai" | "ollama"  (default: "openai")
 *   OPENJAW_LLM_MODEL      - Model name            (default: "gpt-5.6-sol")
 *   OPENAI_API_KEY         - Required for OpenAI provider
 *   OLLAMA_BASE_URL        - Ollama server URL      (default: localhost:11434)
 *   BRAVE_API_KEY          - Required for web search tool
 *   OPENJAW_MEMORY_DIR     - Session storage dir    (default: ~/.openjaw/sessions)
 *   OPENJAW_REMOTE_MCP_URL - Optional remote MCP server URL (HTTP SSE)
 */
public class OpenJawApplication {

    private static final Logger log =
        LoggerFactory.getLogger(OpenJawApplication.class);

    public static void main(String[] args) {
        log.info("Starting OpenJaw Agentic AI Platform");
        log.info("Java version: {}", System.getProperty("java.version"));

        McpToolAdapter mcpToolAdapter = null;

        try {
            // -------------------------------------------------------
            // STEP 1: Create the LLM adapter.
            // -------------------------------------------------------
            log.info("Initializing LLM adapter...");
            LlmPort llmPort = LlmAdapterSelector.createFromEnvironment();
            log.info("LLM adapter ready: {}", llmPort.getProviderIdentifier());

            // -------------------------------------------------------
            // STEP 2: Create the MCP tool adapter.
            // -------------------------------------------------------
            log.info("Initializing MCP tool connections...");
            List<McpServerConnection> mcpConnections = createMcpConnections();
            mcpToolAdapter = new McpToolAdapter(mcpConnections);

            ToolPort toolPort = mcpToolAdapter;
            List<String> toolNames = toolPort.listAvailableTools()
                .stream()
                .map(t -> t.name())
                .toList();
            log.info("Available tools: {}", toolNames);

            // -------------------------------------------------------
            // STEP 3: Create the memory adapter.
            // -------------------------------------------------------
            log.info("Initializing memory adapter...");
            String memoryDirStr = System.getenv()
                .getOrDefault("OPENJAW_MEMORY_DIR",
                    System.getProperty("user.home") + "/.openjaw/sessions");
            Path memoryDir = Path.of(memoryDirStr);
            MemoryPort memoryPort = new FileSystemMemoryAdapter(memoryDir);
            log.info("Memory storage: {}", memoryDir.toAbsolutePath());

            // -------------------------------------------------------
            // STEP 4: Wire the domain service.
            // -------------------------------------------------------
            log.info("Wiring domain service...");
            RunAgentUseCase agentUseCase = new AgentOrchestrator(
                llmPort, toolPort, memoryPort
            );

            // -------------------------------------------------------
            // STEP 5: Register shutdown hook.
            // -------------------------------------------------------
            final McpToolAdapter finalMcpAdapter = mcpToolAdapter;
            Runtime.getRuntime().addShutdownHook(
                new Thread(() -> {
                    log.info("Shutdown hook triggered, cleaning up...");
                    try {
                        finalMcpAdapter.close();
                    } catch (Exception e) {
                        log.warn("Error during MCP cleanup: {}",
                            e.getMessage());
                    }
                    log.info("OpenJaw shutdown complete.");
                }, "openjaw-shutdown")
            );

            // -------------------------------------------------------
            // STEP 6: Start the CLI adapter (blocks until user exits).
            // -------------------------------------------------------
            log.info("Starting CLI adapter...");
            new CliAdapter(agentUseCase).start();

        } catch (Exception e) {
            log.error("Fatal error during startup: {}", e.getMessage(), e);
            System.err.println("\nFATAL ERROR: " + e.getMessage());
            System.err.println("Check the logs for details.");
            System.exit(1);
        }

        log.info("OpenJaw exited normally.");
    }

    /**
     * Creates and initializes all MCP server connections.
     *
     * The web search server is launched as a subprocess of this JVM,
     * communicating via STDIO. The BRAVE_API_KEY environment variable
     * is inherited by the subprocess automatically — the OS handles
     * environment inheritance for child processes.
     *
     * To add more MCP servers, add more connections to this list.
     * No other code needs to change.
     */
    private static List<McpServerConnection> createMcpConnections() {
        List<McpServerConnection> connections = new ArrayList<>();

        // Web Search MCP Server (STDIO transport).
        // We launch WebSearchMcpServer from the same fat JAR as a subprocess.
        String braveApiKey = System.getenv("BRAVE_API_KEY");
        if (braveApiKey != null && !braveApiKey.isBlank()) {
            log.info("Connecting to Web Search MCP server (Brave Search)...");
            try {
                // Resolve the java executable from the current process.
                String javaExecutable = ProcessHandle.current()
                    .info()
                    .command()
                    .orElse("java");

                // In a fat JAR, java.class.path is the fat JAR itself.
                String classPath = System.getProperty("java.class.path");

                McpServerConnection webSearchConn =
                    McpConnectionFactory.createStdioConnection(
                        "web-search",
                        javaExecutable,
                        List.of(
                            "--enable-preview",
                            "-cp", classPath,
                            "com.openjaw.adapters.mcp.server.WebSearchMcpServer"
                        )
                    );
                connections.add(webSearchConn);
                log.info("Web Search MCP server connected.");
            } catch (Exception e) {
                log.warn("Failed to connect to Web Search MCP server: {}. "
                    + "Web search will be unavailable.", e.getMessage());
            }
        } else {
            log.warn("BRAVE_API_KEY not set. "
                + "Web search tool will be unavailable.");
        }

        // Optional: Remote MCP server via HTTP SSE.
        String remoteMcpUrl = System.getenv("OPENJAW_REMOTE_MCP_URL");
        if (remoteMcpUrl != null && !remoteMcpUrl.isBlank()) {
            log.info("Connecting to remote MCP server at {}...", remoteMcpUrl);
            try {
                McpServerConnection remoteConn =
                    McpConnectionFactory.createHttpSseConnection(
                        "remote-tools", remoteMcpUrl
                    );
                connections.add(remoteConn);
                log.info("Remote MCP server connected.");
            } catch (Exception e) {
                log.warn("Failed to connect to remote MCP server at {}: {}",
                    remoteMcpUrl, e.getMessage());
            }
        }

        if (connections.isEmpty()) {
            log.warn("No MCP servers connected. The agent will have no tools "
                + "and can only answer from its training data.");
        }

        return connections;
    }
}

CHAPTER NINE: TESTING THE DOMAIN IN ISOLATION

9.1 Pure Domain Tests Without Any External Dependencies

One of the most powerful benefits of hexagonal architecture is that the domain can be tested in complete isolation. No LLM API calls, no MCP servers, no file system access. Just pure Java logic with simple anonymous implementations of the ports. This makes tests fast (milliseconds, not seconds), reliable (no network flakiness), and deterministic (always the same result).

File: openjaw-domain/src/test/java/com/openjaw/domain/service/AgentOrchestratorTest.java

package com.openjaw.domain.service;

import com.openjaw.domain.model.AgentMemory;
import com.openjaw.domain.model.Message;
import com.openjaw.domain.model.ToolCall;
import com.openjaw.domain.model.ToolDefinition;
import com.openjaw.domain.port.in.RunAgentUseCase;
import com.openjaw.domain.port.out.LlmPort;
import com.openjaw.domain.port.out.MemoryPort;
import com.openjaw.domain.port.out.ToolPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

/**
 * Unit tests for the AgentOrchestrator domain service.
 *
 * These tests verify the ReAct loop logic WITHOUT any external dependencies.
 * All ports are implemented as simple anonymous classes (test doubles)
 * that return pre-configured responses.
 *
 * These tests run in milliseconds and give you confidence that the core
 * business logic is correct — before you ever touch a real LLM or MCP server.
 */
class AgentOrchestratorTest {

    private UUID sessionId;

    @BeforeEach
    void setUp() {
        sessionId = UUID.randomUUID();
    }

    @Test
    @DisplayName("Direct response: LLM answers without calling any tools")
    void shouldReturnDirectResponseWhenNoToolCallNeeded() {
        LlmPort mockLlm = (memory, tools) ->
            Message.AssistantMessage.ofText("The answer is 42.");

        ToolPort mockToolPort = new ToolPort() {
            @Override
            public List<ToolDefinition> listAvailableTools() {
                return List.of();
            }

            @Override
            public Message.ToolResultMessage executeTool(ToolCall toolCall) {
                fail("executeTool should not be called in this test!");
                return null;
            }
        };

        AgentOrchestrator orchestrator = new AgentOrchestrator(
            mockLlm, mockToolPort, createInMemoryMemoryPort()
        );

        RunAgentUseCase.AgentResponse response =
            orchestrator.run(sessionId, "What is the answer to everything?");

        assertEquals("The answer is 42.", response.responseText());
        assertEquals(0, response.toolCallCount());
        assertTrue(response.toolsUsed().isEmpty());
        assertEquals(sessionId, response.sessionId());
    }

    @Test
    @DisplayName("Tool call cycle: LLM calls a tool, then answers with the result")
    void shouldExecuteToolAndIncludeResultInFinalResponse() {
        final int[] llmCallCount = {0};

        ToolCall expectedToolCall = ToolCall.of(
            "web_search",
            Map.of("query", "current Java 21 adoption rate 2026")
        );

        LlmPort mockLlm = (memory, tools) -> {
            llmCallCount[0]++;
            if (llmCallCount[0] == 1) {
                return Message.AssistantMessage.ofToolCall(expectedToolCall);
            } else {
                return Message.AssistantMessage.ofText(
                    "Based on the search results, Java 21 adoption is at 67%."
                );
            }
        };

        ToolPort mockToolPort = new ToolPort() {
            @Override
            public List<ToolDefinition> listAvailableTools() {
                return List.of(ToolDefinition.simple(
                    "web_search",
                    "Search the web for current information.",
                    List.of("query")
                ));
            }

            @Override
            public Message.ToolResultMessage executeTool(ToolCall toolCall) {
                assertEquals("web_search", toolCall.toolName());
                assertEquals(
                    "current Java 21 adoption rate 2026",
                    toolCall.arguments().get("query")
                );
                return Message.ToolResultMessage.success(
                    toolCall.callId(),
                    toolCall.toolName(),
                    "Java 21 adoption has reached 67% in enterprise environments."
                );
            }
        };

        AgentOrchestrator orchestrator = new AgentOrchestrator(
            mockLlm, mockToolPort, createInMemoryMemoryPort()
        );

        RunAgentUseCase.AgentResponse response = orchestrator.run(
            sessionId, "What is the current Java 21 adoption rate?"
        );

        assertEquals(
            "Based on the search results, Java 21 adoption is at 67%.",
            response.responseText()
        );
        assertEquals(1, response.toolCallCount());
        assertTrue(response.toolsUsed().contains("web_search"));
        assertEquals(2, llmCallCount[0],
            "LLM should have been called exactly twice: "
            + "once to request the tool, once to answer with results.");
    }

    @Test
    @DisplayName("Max iterations: agent stops after hitting the iteration limit")
    void shouldStopAfterMaxIterationsAndReturnFallbackMessage() {
        // The LLM always requests a tool, never giving a final answer.
        // This simulates a runaway agent that keeps calling tools forever.
        LlmPort infiniteLoopLlm = (memory, tools) ->
            Message.AssistantMessage.ofToolCall(
                ToolCall.of("web_search", Map.of("query", "something"))
            );

        ToolPort alwaysSuccessfulToolPort = new ToolPort() {
            @Override
            public List<ToolDefinition> listAvailableTools() {
                return List.of(ToolDefinition.simple(
                    "web_search", "Search the web.", List.of("query")
                ));
            }

            @Override
            public Message.ToolResultMessage executeTool(ToolCall toolCall) {
                return Message.ToolResultMessage.success(
                    toolCall.callId(), toolCall.toolName(), "Some result."
                );
            }
        };

        AgentOrchestrator orchestrator = new AgentOrchestrator(
            infiniteLoopLlm, alwaysSuccessfulToolPort,
            createInMemoryMemoryPort()
        );

        RunAgentUseCase.AgentResponse response = orchestrator.run(
            sessionId, "This will trigger the iteration limit"
        );

        assertNotNull(response.responseText());
        assertTrue(
            response.responseText().contains("unable to complete"),
            "Fallback message should explain the situation to the user."
        );
        // MAX_ITERATIONS = 10 in AgentOrchestrator.
        assertEquals(10, response.toolCallCount());
    }

    /**
     * Creates a simple in-memory implementation of MemoryPort for testing.
     * Stores sessions in a HashMap and never touches the file system.
     */
    private MemoryPort createInMemoryMemoryPort() {
        Map<UUID, AgentMemory> store = new HashMap<>();

        return new MemoryPort() {
            @Override
            public void save(AgentMemory memory) {
                store.put(memory.getSessionId(), memory);
            }

            @Override
            public Optional<AgentMemory> load(UUID sessionId) {
                return Optional.ofNullable(store.get(sessionId));
            }

            @Override
            public AgentMemory createNew(UUID sessionId) {
                return new AgentMemory(sessionId, 100);
            }
        };
    }
}

These tests run in milliseconds and verify the most critical behavior of the entire system. Because the domain is completely isolated from infrastructure, we can test every edge case — direct responses, tool calls, error handling, iteration limits — without any external setup.


CHAPTER TEN: RUNNING OPENJAW

10.1 Prerequisites

Before you build and run OpenJaw, make sure you have the following installed:

Java 21 or later:

java -version
# Should show: openjdk version "21.x.x" or similar

Maven 3.9+:

mvn -version
# Should show: Apache Maven 3.9.x

Ollama (optional, for local LLM):

# Install from https://ollama.ai, then pull a model:
ollama pull llama3.3:70b
# Or for a lighter model on machines without a powerful GPU:
ollama pull mistral:7b

API Keys (optional, for cloud LLMs and web search):

10.2 Building the Application

Build the entire project from the root directory:

cd openjaw
mvn clean package -DskipTests

This compiles all modules and produces a fat JAR in:

openjaw-bootstrap/target/openjaw-bootstrap-1.0.0-SNAPSHOT.jar

To also run the unit tests (fast, no external dependencies required):

mvn clean package

10.3 Running with OpenAI (Cloud LLM)

export OPENAI_API_KEY=sk-your-openai-key-here
export BRAVE_API_KEY=your-brave-search-key-here
export OPENJAW_LLM_PROVIDER=openai
export OPENJAW_LLM_MODEL=gpt-5.6-sol

java --enable-preview \
     -jar openjaw-bootstrap/target/openjaw-bootstrap-1.0.0-SNAPSHOT.jar

To redirect logs to a file and keep the terminal clean:

java --enable-preview \
     -jar openjaw-bootstrap/target/openjaw-bootstrap-1.0.0-SNAPSHOT.jar \
     2>~/.openjaw/logs/openjaw.log

10.4 Running with Ollama (Local LLM — No Data Leaves Your Machine)

# Start Ollama in a separate terminal first:
ollama serve

# Then run OpenJaw:
export BRAVE_API_KEY=your-brave-search-key-here
export OPENJAW_LLM_PROVIDER=ollama
export OPENJAW_LLM_MODEL=llama3.3:70b
export OLLAMA_BASE_URL=http://localhost:11434

java --enable-preview \
     -jar openjaw-bootstrap/target/openjaw-bootstrap-1.0.0-SNAPSHOT.jar

10.5 Running Without Web Search (No API Keys Required)

If you just want to try OpenJaw without any API keys, run it with a local Ollama model and no web search tool. The agent will answer from the model's training data only:

ollama pull mistral:7b

export OPENJAW_LLM_PROVIDER=ollama
export OPENJAW_LLM_MODEL=mistral:7b

java --enable-preview \
     -jar openjaw-bootstrap/target/openjaw-bootstrap-1.0.0-SNAPSHOT.jar

10.6 Connecting to a Remote MCP Server

If you have an MCP server running on a remote host (or in a Docker container), connect to it via HTTP SSE:

export OPENJAW_REMOTE_MCP_URL=http://your-mcp-server:8080

java --enable-preview \
     -jar openjaw-bootstrap/target/openjaw-bootstrap-1.0.0-SNAPSHOT.jar

10.7 A Sample Interaction

When the application starts, you will see the banner and a session ID:

╔══════════════════════════════════════════════════════════╗
║           OPENJAW Agentic AI Platform v1.0               ║
║           Built with Java 21 + LangChain4j 1.18.0        ║
║           MCP Tool Protocol v0.17.0                       ║
╚══════════════════════════════════════════════════════════╝
Type your message and press Enter. Type 'exit' to quit.
Type 'new' to start a fresh session.

Session ID: 7f3a9b2c-1234-5678-abcd-ef0123456789
(Memory will be saved and can be resumed later)

You: What are the most significant Java releases in 2026 so far?

OpenJaw: [thinking....]

OpenJaw: Based on my web search, here are the most significant
Java developments in 2026:

Java 24 was released in March 2026, bringing several important features
including the finalization of JEP 491 (Synchronize Virtual Threads without
Pinning), which resolved a key limitation of Virtual Threads introduced
in Java 21. This makes Virtual Threads even more powerful for I/O-bound
workloads.

Java 25 is scheduled for September 2026 as the next Long-Term Support (LTS)
release, following Java 21. It is expected to finalize Structured Concurrency
and Scoped Values, which have been in preview since Java 21.

[Used 1 tool(s): web_search | 3.2 seconds]

You: exit

Goodbye! Your session has been saved.

The session is automatically saved after each interaction. To resume it later, you can extend the CliAdapter to accept a session ID as a command-line argument — a straightforward enhancement that follows naturally from the architecture.


CHAPTER ELEVEN: ARCHITECTURAL REFLECTIONS AND NEXT STEPS

11.1 What We Built and Why It Matters

Let us step back and appreciate what we have actually built. OpenJaw is not a toy. It is a production-quality foundation for an enterprise agentic AI platform. Every design decision we made has a reason.

The hexagonal architecture means that OpenJaw can evolve without pain. When GPT-6 comes out next year, you write one new factory method in the LLM adapter module. When your team wants to add a database query tool, you write one new MCP server. When the product team wants a web UI instead of a CLI, you write one new driving adapter. The domain — the ReAct loop, the memory management, the tool orchestration — never changes.

The MCP-first tool integration means that OpenJaw can use any tool from the rapidly growing MCP ecosystem. There are already hundreds of MCP servers available for everything from GitHub to Kubernetes to Slack to PostgreSQL. Every one of them works with OpenJaw out of the box.

The Virtual Thread-based concurrency means that OpenJaw scales effortlessly. Each agent request runs on its own virtual thread, and parallel tool execution uses Structured Concurrency for safety and readability. You can handle thousands of concurrent agent sessions on a single JVM without the complexity of reactive programming.

The pure domain tests mean that the most critical business logic is tested in milliseconds without any external dependencies. This is the foundation of a reliable CI/CD pipeline.

11.2 Improvements Over the Original Hermes Agent

Let us be specific about how OpenJaw improves upon the original Hermes Agent from Nous Research.

The architecture is formally structured. OpenJaw applies hexagonal architecture from the ground up, giving every component a clear, single responsibility and making the dependency graph explicit and enforced by Maven module boundaries.

MCP is a first-class citizen. In OpenJaw, MCP is the primary and only tool integration mechanism. Every tool is an MCP server. This means the tool ecosystem is immediately available and standardized.

Java's type system provides compile-time safety. The sealed Message interface hierarchy means the compiler catches missing cases. The record types mean all domain objects are immutable by default. The strict port interfaces mean you cannot accidentally call an adapter from the domain.

Virtual Threads and Structured Concurrency provide scalable, readable concurrency without the complexity of reactive programming.

The memory system is extensible by design. The MemoryPort interface can be implemented with a vector database for semantic search, a relational database for structured queries, or a distributed cache for multi-instance deployments.

11.3 Suggested Next Steps

OpenJaw as presented is a solid foundation. Here are the most impactful enhancements to build next, roughly in order of value delivered.

Adding a vector memory adapter would dramatically improve the agent's ability to recall relevant information from long conversation histories. Instead of keeping the last N messages, a vector memory adapter would store all messages as embeddings and retrieve the most semantically relevant ones for each new query. LangChain4j provides excellent support for embedding models and vector stores.

Implementing a planning layer would allow OpenJaw to handle complex multi-step tasks more reliably. Before entering the ReAct loop, a planner could break the user's request into subtasks and execute them in sequence or in parallel.

Adding observability with structured logging and metrics would make OpenJaw production-ready. Every LLM call, every tool execution, and every memory operation should emit structured log events and metrics that can be collected by Prometheus and visualized in Grafana.

Building a REST API adapter would allow OpenJaw to be integrated into existing systems. A simple Javalin or Helidon adapter that exposes the RunAgentUseCase as an HTTP endpoint would make OpenJaw accessible from any language or platform.

Implementing session management with user authentication would allow OpenJaw to serve multiple users safely. Each user would have their own isolated session namespace, and the memory adapter would enforce access control.

Adding support for streaming responses would dramatically improve the user experience. Instead of waiting for the entire response before displaying anything, the CLI adapter could display tokens as they arrive from the LLM. LangChain4j supports streaming through its StreamingChatLanguageModel interface.

11.4 A Final Word on the Philosophy

Building an agentic AI platform is not primarily a machine learning problem. It is a software engineering problem. The LLMs are commodities — you can swap them in and out. The tools are commodities — MCP standardizes them. What differentiates a great agentic AI platform from a mediocre one is the quality of the software engineering around the LLMs and tools.

Hexagonal architecture, clean code, proper testing, and thoughtful concurrency design are not optional extras that you add after the AI works. They are the foundation that makes the AI work reliably in production, at scale, over time.

OpenJaw is our answer to the question: what does a production-quality Java agentic AI platform look like? It looks like good software engineering applied to an exciting new problem domain. It looks like interfaces and adapters and ports and domain services. It looks like virtual threads and structured concurrency and sealed interfaces and records.

It looks, in short, like Java done right. And honestly? That is something to be genuinely proud of.


APPENDIX: QUICK REFERENCE

Technology Stack Summary (as of July 2026)

ComponentLibraryVersion
RuntimeJava21
LLM IntegrationLangChain4j BOM1.18.0
Tool ProtocolMCP Java SDK0.17.0
Local LLMsOllama4j1.1.6
JSON CoreJackson Databind2.17.2
JSON Recordsjackson-module-parameter-names2.17.2
JSON Optionaljackson-datatype-jdk82.17.2
JSON DateTimejackson-datatype-jsr3102.17.2
Logging APISLF4J2.0.13
Logging ImplLogback1.5.6
TestingJUnit Jupiter5.10.3
MockingMockito5.12.0

Environment Variables Reference

VariableDescriptionDefault
OPENJAW_LLM_PROVIDERLLM provider: openai or ollamaopenai
OPENJAW_LLM_MODELModel namegpt-5.6-sol / llama3.3:70b
OPENAI_API_KEYOpenAI API key (required for OpenAI)
OLLAMA_BASE_URLOllama server URLhttp://localhost:11434
BRAVE_API_KEYBrave Search API key (for web search)
OPENJAW_MEMORY_DIRSession storage directory~/.openjaw/sessions
OPENJAW_REMOTE_MCP_URLRemote MCP server URL (HTTP SSE)

Package Naming Convention

Maven Artifact IDJava Package Root
openjaw-domaincom.openjaw.domain.*
openjaw-adapters-llmcom.openjaw.adapters.llm.*
openjaw-adapters-mcpcom.openjaw.adapters.mcp.*
openjaw-adapters-memorycom.openjaw.adapters.memory.*
openjaw-adapters-uicom.openjaw.adapters.ui.*
openjaw-bootstrapcom.openjaw

Maven artifact IDs use hyphens. Java packages use dots. These are two completely separate naming systems — never confuse them.

Module Dependency Graph

openjaw-bootstrap
    ├── openjaw-adapters-llm
    │       └── openjaw-domain
    ├── openjaw-adapters-mcp
    │       └── openjaw-domain
    ├── openjaw-adapters-memory
    │       └── openjaw-domain
    └── openjaw-adapters-ui
            └── openjaw-domain

The domain module has zero dependencies on any adapter module. This is the core invariant of hexagonal architecture, and Maven enforces it through module boundaries.

Key Architectural Patterns Used

Hexagonal Architecture (Ports and Adapters) isolates the domain from all infrastructure concerns and makes every component independently testable and replaceable.

Strategy Pattern applied to LLM selection, allowing runtime switching between OpenAI, Ollama, and future providers without changing domain code.

Factory Method Pattern for creating LLM model instances and MCP connections, encapsulating all provider-specific configuration in dedicated factory classes.

Repository Pattern (via MemoryPort) abstracts memory persistence, allowing the storage backend to be swapped without touching the domain.

Composition Root Pattern concentrates all dependency wiring in the bootstrap module's main class, making the application's structure transparent and testable.

Anti-Corruption Layer implemented in each adapter, translating between the domain's language and the external library's language to prevent framework concepts from leaking into the domain.



No comments: