Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
.gradle
build
.idea
*.iml
.DS_Store
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
push:
branches: [main, "feature/*"]
pull_request:
branches: [main]

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Build and Test
run: ./gradlew build test

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: build/reports/tests/test/

docker-build:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4

- name: Build Docker image
run: docker build -t openclaw-java:${{ github.sha }} .
32 changes: 32 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
FROM gradle:8-jdk21 AS build
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY gradle ./gradle
# Download dependencies first (cached layer)
RUN gradle dependencies --no-daemon || true
COPY src ./src
RUN gradle jar --no-daemon

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Install bash for CodeExecutionTool
RUN apk add --no-cache bash curl

# Create a non-root user with a dedicated workspace for tool execution
RUN addgroup -S openclaw && adduser -S openclaw -G openclaw \
&& mkdir -p /home/openclaw/workspace \
&& chown -R openclaw:openclaw /home/openclaw

COPY --from=build /app/build/libs/*.jar /app/openclaw.jar

# Default gateway port
ENV GATEWAY_PORT=18789
EXPOSE ${GATEWAY_PORT}

# Run as non-root user
USER openclaw
ENV HOME=/home/openclaw

ENTRYPOINT ["java", "-jar", "/app/openclaw.jar"]
CMD ["gateway"]
113 changes: 101 additions & 12 deletions src/main/java/ai/openclaw/agent/AgentExecutor.java
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
package ai.openclaw.agent;

import ai.openclaw.config.Json;
import ai.openclaw.config.OpenClawConfig;
import ai.openclaw.session.Message;
import ai.openclaw.session.Session;
import ai.openclaw.session.SessionStore;
import ai.openclaw.tool.Tool;
import ai.openclaw.tool.ToolResult;

import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

public class AgentExecutor {
private static final Logger logger = LoggerFactory.getLogger(AgentExecutor.class);
private static final int MAX_TOOL_ITERATIONS = 10;

private final OpenClawConfig config;
private final SessionStore sessionStore;
private final LlmProvider llmProvider;
private final SystemPromptBuilder promptBuilder;
private final List<Tool> tools;
private final Map<String, Tool> toolMap;

public AgentExecutor(OpenClawConfig config, SessionStore sessionStore, LlmProvider llmProvider) {
this(config, sessionStore, llmProvider, List.of());
}

public AgentExecutor(OpenClawConfig config, SessionStore sessionStore, LlmProvider llmProvider, List<Tool> tools) {
this.config = config;
this.sessionStore = sessionStore;
this.llmProvider = llmProvider;
this.promptBuilder = new SystemPromptBuilder(config);
this.tools = tools;
this.toolMap = new HashMap<>();
for (Tool tool : tools) {
this.toolMap.put(tool.name(), tool);
}
}

public String execute(String sessionId, String userMessage) {
Expand All @@ -35,27 +56,95 @@ public String execute(String sessionId, String userMessage) {
Message userMsg = new Message("user", userMessage);
sessionStore.appendMessage(sessionId, userMsg);

// 3. Build Context
List<Message> context = new ArrayList<>();
// System Prompt
context.add(new Message("system", promptBuilder.build()));
// History
context.addAll(session.getMessages());

// 4. Call LLM
// 3. Run the agentic loop
String responseText;
try {
String model = config.getAgent().getModel();
responseText = llmProvider.complete(context, model);
responseText = runAgentLoop(sessionId, session);
} catch (Exception e) {
logger.error("LLM Provider failed", e);
logger.error("Agent loop failed", e);
responseText = "Error: " + e.getMessage();
}

// 5. Append Assistant Message
// 4. Append final Assistant Message
Message assistantMsg = new Message("assistant", responseText);
sessionStore.appendMessage(sessionId, assistantMsg);

return responseText;
}

private String runAgentLoop(String sessionId, Session session) throws Exception {
String model = config.getAgent().getModel();

for (int iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
// Build context from session history
List<Message> context = new ArrayList<>();
context.add(new Message("system", promptBuilder.build()));
context.addAll(session.getMessages());

// Call LLM with tools
LlmResponse response;
if (!tools.isEmpty()) {
response = llmProvider.completeWithTools(context, model, tools);
} else {
String text = llmProvider.complete(context, model);
return text;
}

if (!response.hasToolUse()) {
// No tool use — return the text content
return response.getTextContent();
}

// The LLM wants to use tools
logger.info("Tool use requested (iteration {})", iteration + 1);

// Store the assistant's response (with tool_use blocks) in the session
// so it can be replayed in the next API call
ArrayNode contentBlocksJson = serializeContentBlocks(response.getContent());
Message assistantToolMsg = Message.assistantToolUse(contentBlocksJson);
sessionStore.appendMessage(sessionId, assistantToolMsg);

// Execute each requested tool and add results to session
for (LlmResponse.ContentBlock block : response.getToolUseBlocks()) {
Tool tool = toolMap.get(block.getToolName());
ToolResult result;
if (tool != null) {
logger.info("Executing tool: {} (id: {})", block.getToolName(), block.getToolUseId());
result = tool.execute(block.getToolInput());
} else {
logger.warn("Unknown tool requested: {}", block.getToolName());
result = ToolResult.error("Unknown tool: " + block.getToolName());
}

Message toolResultMsg = Message.toolResult(
block.getToolUseId(),
result.getOutput(),
result.isError());
sessionStore.appendMessage(sessionId, toolResultMsg);
}

// Loop back — the next iteration will include the tool results in context
}

logger.warn("Agent loop hit max iterations ({})", MAX_TOOL_ITERATIONS);
return "I've reached the maximum number of tool use steps. Here's what I have so far — please try rephrasing your request if you need more.";
}

/** Serialize content blocks back to the JSON format Anthropic expects. */
private ArrayNode serializeContentBlocks(List<LlmResponse.ContentBlock> blocks) {
ArrayNode array = Json.mapper().createArrayNode();
for (LlmResponse.ContentBlock block : blocks) {
ObjectNode node = array.addObject();
if ("text".equals(block.getType())) {
node.put("type", "text");
node.put("text", block.getText());
} else if ("tool_use".equals(block.getType())) {
node.put("type", "tool_use");
node.put("id", block.getToolUseId());
node.put("name", block.getToolName());
node.set("input", block.getToolInput());
}
}
return array;
}
}
82 changes: 80 additions & 2 deletions src/main/java/ai/openclaw/agent/AnthropicProvider.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package ai.openclaw.agent;

import ai.openclaw.session.Message;
import ai.openclaw.config.Json;
import ai.openclaw.session.Message;
import ai.openclaw.tool.Tool;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import okhttp3.*;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

Expand All @@ -29,27 +31,79 @@ public AnthropicProvider(String apiKey) {

@Override
public String complete(List<Message> messages, String model) throws IOException {
LlmResponse response = completeWithTools(messages, model, List.of());
return response.getTextContent();
}

@Override
public LlmResponse completeWithTools(List<Message> messages, String model, List<Tool> tools) throws IOException {
ObjectNode requestBody = mapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("max_tokens", 4096);

// Build messages array
ArrayNode messagesArray = requestBody.putArray("messages");
String systemPrompt = null;

ArrayNode lastToolResultContentArray = null;

for (Message msg : messages) {
if ("system".equals(msg.getRole())) {
systemPrompt = msg.getContent();
lastToolResultContentArray = null;
} else if ("tool_result".equals(msg.getRole())) {
// Merge consecutive tool_results into a single user message
ArrayNode contentArray;
if (lastToolResultContentArray != null) {
// Append to existing user message
contentArray = lastToolResultContentArray;
} else {
// Create a new user message
ObjectNode messageNode = messagesArray.addObject();
messageNode.put("role", "user");
contentArray = messageNode.putArray("content");
lastToolResultContentArray = contentArray;
}
ObjectNode toolResultBlock = contentArray.addObject();
toolResultBlock.put("type", "tool_result");
toolResultBlock.put("tool_use_id", msg.getToolUseId());
toolResultBlock.put("content", msg.getContent());
if (msg.isToolError()) {
toolResultBlock.put("is_error", true);
}
} else if ("assistant_tool_use".equals(msg.getRole())) {
// Reconstruct the assistant message with tool_use content blocks
ObjectNode messageNode = messagesArray.addObject();
messageNode.put("role", "assistant");
if (msg.getContentBlocks() != null) {
messageNode.set("content", msg.getContentBlocks());
} else {
messageNode.put("content", msg.getContent());
}
lastToolResultContentArray = null;
} else {
ObjectNode messageNode = messagesArray.addObject();
messageNode.put("role", msg.getRole());
messageNode.put("content", msg.getContent());
lastToolResultContentArray = null;
}
}

if (systemPrompt != null) {
requestBody.put("system", systemPrompt);
}

// Add tool definitions if provided
if (tools != null && !tools.isEmpty()) {
ArrayNode toolsArray = requestBody.putArray("tools");
for (Tool tool : tools) {
ObjectNode toolNode = toolsArray.addObject();
toolNode.put("name", tool.name());
toolNode.put("description", tool.description());
toolNode.set("input_schema", tool.inputSchema());
}
}

RequestBody body = RequestBody.create(
mapper.writeValueAsString(requestBody),
MediaType.parse("application/json"));
Expand All @@ -69,8 +123,32 @@ public String complete(List<Message> messages, String model) throws IOException
}

JsonNode jsonResponse = mapper.readTree(response.body().byteStream());
return jsonResponse.get("content").get(0).get("text").asText();
return parseResponse(jsonResponse);
}
}

private LlmResponse parseResponse(JsonNode jsonResponse) {
String stopReason = jsonResponse.has("stop_reason")
? jsonResponse.get("stop_reason").asText()
: "end_turn";

List<LlmResponse.ContentBlock> blocks = new ArrayList<>();
JsonNode contentArray = jsonResponse.get("content");
if (contentArray != null && contentArray.isArray()) {
for (JsonNode block : contentArray) {
String type = block.get("type").asText();
if ("text".equals(type)) {
blocks.add(LlmResponse.ContentBlock.text(block.get("text").asText()));
} else if ("tool_use".equals(type)) {
blocks.add(LlmResponse.ContentBlock.toolUse(
block.get("id").asText(),
block.get("name").asText(),
block.get("input")));
}
}
}

return new LlmResponse(stopReason, blocks);
}

@Override
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/ai/openclaw/agent/LlmProvider.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package ai.openclaw.agent;

import ai.openclaw.session.Message;
import ai.openclaw.tool.Tool;
import java.util.List;

public interface LlmProvider {
/** Simple text-only completion (no tools). */
String complete(List<Message> messages, String model) throws Exception;

/**
* Completion with tool definitions — returns structured response with possible
* tool_use blocks.
*/
LlmResponse completeWithTools(List<Message> messages, String model, List<Tool> tools) throws Exception;

String providerName();
}
Loading