Skip to content

Commit 4d37869

Browse files
authored
Easier support for multiple agents (#47)
* Easier support for multiple agents * Add Github actions (🤫 this should have been there from the start).
1 parent babee09 commit 4d37869

27 files changed

Lines changed: 405 additions & 147 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Java 25
17+
uses: actions/setup-java@v4
18+
with:
19+
java-version: '25'
20+
distribution: 'temurin'
21+
cache: 'gradle'
22+
23+
- name: Run tests
24+
run: ./gradlew test

AGENTS.md

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,22 @@ This project represents a Java version of OpenClaw. OpenClaw is an open-source,
2626

2727
```
2828
root
29-
├── base/ ← Core: agent, tasks, tools, channels, config
30-
├── app/ ← Spring Boot entry point, onboarding UI, web routes, chat channel
31-
└── channels/
32-
└── telegram/ ← Telegram long-poll channel
29+
├── base/ ← Core: agent, tasks, tools, channels, config
30+
├── app/ ← Spring Boot entry point, onboarding UI, web routes, chat channel
31+
├── providers/
32+
│ ├── anthropic/ ← Anthropic (Claude) provider + Claude Code OAuth support
33+
│ ├── openai/ ← OpenAI (GPT) provider
34+
│ ├── ollama/ ← Ollama local provider (no API key required)
35+
│ └── google/ ← Google Gen AI (Gemini) provider
36+
└── plugins/
37+
├── telegram/ ← Telegram long-poll channel
38+
├── brave/ ← Brave web search tool
39+
└── playwright/ ← Playwright browser tool
3340
```
3441

35-
`app` depends on `base` + `channels:telegram`. `ChatChannel` lives inside `app/`.
42+
`app` depends on `base` + all `providers/` + all `plugins/`. `ChatChannel` lives inside `app/`.
43+
44+
Each **provider** implements `AgentOnboardingProvider` (in `base`) and is auto-discovered by Spring. Each **plugin** is an optional Spring Boot auto-configuration module that contributes tools or channels.
3645

3746
---
3847

@@ -96,10 +105,16 @@ User/Agent → TaskManager.create()
96105
| `MCP Tools` | `SyncMcpToolCallbackProvider` |
97106
| `BraveWebSearchTool` | 15 results (only if Brave API key configured) |
98107

99-
**Supported LLM Providers** (`SupportedProvider.java`):
100-
- `OLLAMA` — local, no API key required
101-
- `OPENAI` — GPT-5.4
102-
- `ANTHROPIC` — Claude Sonnet 4.6
108+
**Supported LLM Providers** — each lives in its own `providers/<name>/` module and implements `AgentOnboardingProvider`:
109+
110+
| Provider | Module | Default Model | API Key |
111+
|---|---|---|---|
112+
| `anthropic` | `providers/anthropic` | `claude-sonnet-4-6` | Required (or Claude Code OAuth) |
113+
| `openai` | `providers/openai` | `gpt-5.4` | Required |
114+
| `ollama` | `providers/ollama` | `qwen3.5:27b` | Not required (local) |
115+
| `google.genai` | `providers/google` | `gemini-3-flash-preview` | Required |
116+
117+
The `AnthropicAgentOnboardingProvider` additionally supports a **system-wide token** via `AnthropicClaudeCodeOAuthTokenExtractor` — if a Claude Code OAuth token is found locally, it is offered as a zero-config option during onboarding.
103118

104119
---
105120

@@ -146,13 +161,13 @@ Incoming message → ChannelMessageReceivedEvent (channel name, message text)
146161
- **htmx v2.0.8 (https://htmx.org/docs/):** htmx is a strong fit for this app because it keeps the interaction model server-driven: the server returns HTML fragments, not JSON, and htmx swaps them into the DOM. We are using `hx-boost` which "boosts" normal anchors and form tags to use AJAX instead (preventing reloading of css and js). This has the nice fallback that, if the user does not have javascript enabled, the site will continue to work. Both Bulma and htmx are already included in `base.html.peb`.
147162

148163
### Onboarding UI
149-
Entry point: `GET /index``IndexController.java` (redirects to `/onboarding/`) → `OnboardingController.java`. 7-step session-based flow:
164+
Entry point: `GET /index``IndexController.java` (redirects to `/onboarding/`) → `OnboardingController.java`. Session-based flow:
150165
1. Welcome
151-
2. Provider selection (Ollama / OpenAI / Anthropic)
152-
3. Credentials (API key + model)
166+
2. Provider selection — dynamically populated from all `AgentOnboardingProvider` beans (Anthropic, OpenAI, Ollama, Google Gen AI, + any future providers)
167+
3. Credentials (API key + model — skipped for providers where `requiresApiKey()` is `false`)
153168
4. `AGENT.md` editor (system prompt customization)
154169
5. MCP servers configuration (optional)
155-
6. Telegram bot token + allowed username (optional)
170+
6. Plugin-contributed steps (e.g. Telegram bot token, Brave API key, Playwright) — injected by each plugin's `OnboardingProvider`
156171
7. Complete summary
157172

158173
Templates: `templates/onboarding/` (index + 7 step partials). Saves config via `ConfigurationManager.updateProperty()`.
@@ -176,5 +191,6 @@ Templates: `templates/onboarding/` (index + 7 step partials). Saves config via `
176191
## Tests
177192

178193
- `base/src/test/``TaskManagerTest`: task creation, file naming, JobRunr integration (in-memory storage + background server).
179-
- `channels/telegram/src/test/``TelegramChannelTest`: unauthorized user rejection, authorized message flow (mocked).
194+
- `plugins/telegram/src/test/``TelegramChannelTest`: unauthorized user rejection, authorized message flow (mocked).
195+
- `providers/anthropic/src/test/``AnthropicClaudeCodeBackendTest`: Claude Code OAuth token extraction.
180196
- `app/src/test/``OnboardingControllerTest`: session-based workflow; `JavaClawApplicationTests`: full Spring context load with Testcontainers.

app/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ plugins {
66

77
dependencies {
88
implementation project(':base')
9+
implementation project(':providers:anthropic')
10+
implementation project(':providers:google')
11+
implementation project(':providers:ollama')
12+
implementation project(':providers:openai')
13+
914
implementation project(':plugins:telegram')
1015
implementation project(':plugins:playwright')
1116
implementation project(':plugins:brave')

app/src/main/java/ai/javaclaw/SupportedProvider.java

Lines changed: 0 additions & 88 deletions
This file was deleted.

app/src/main/java/ai/javaclaw/onboarding/api/OnboardingController.java

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
package ai.javaclaw.onboarding.api;
22

3-
import ai.javaclaw.SupportedProvider;
43
import ai.javaclaw.configuration.ConfigurationManager;
4+
import ai.javaclaw.onboarding.AgentOnboardingProviders;
55
import ai.javaclaw.onboarding.OnboardingProvider;
66
import jakarta.servlet.http.HttpSession;
7-
import org.springframework.core.env.Environment;
87
import org.springframework.stereotype.Controller;
98
import org.springframework.ui.Model;
109
import org.springframework.web.bind.annotation.GetMapping;
@@ -24,12 +23,12 @@ public class OnboardingController {
2423
private static final String COMPLETE_STEP_ID = "complete";
2524
private static final String ONBOARDING_TEMPLATE = "onboarding/index";
2625

27-
private final Environment environment;
26+
private final AgentOnboardingProviders agentOnboardingProviders;
2827
private final ConfigurationManager configurationManager;
2928
private final List<OnboardingProvider> steps;
3029

31-
public OnboardingController(Environment environment, ConfigurationManager configurationManager, List<OnboardingProvider> steps) {
32-
this.environment = environment;
30+
public OnboardingController(AgentOnboardingProviders agentOnboardingProviders, ConfigurationManager configurationManager, List<OnboardingProvider> steps) {
31+
this.agentOnboardingProviders = agentOnboardingProviders;
3332
this.configurationManager = configurationManager;
3433
this.steps = steps;
3534
}
@@ -41,15 +40,16 @@ public String onboarding() {
4140

4241
@GetMapping("/onboarding/{stepId}")
4342
public String getStep(@PathVariable String stepId, HttpSession session, Model model) {
44-
OnboardingProvider onboardingProvider = findProvider(stepId);
43+
OnboardingProvider onboardingProvider = findOnboardingProvider(stepId);
4544
if (onboardingProvider == null) {
4645
return "redirect:/onboarding/" + steps.getFirst().getStepId();
4746
}
4847

4948
// When arriving at the complete step via GET (e.g. by skipping the last optional step),
5049
// save configuration if the session still holds onboarding data.
5150
if (COMPLETE_STEP_ID.equals(stepId) && session.getAttribute("onboarding.provider") != null) {
52-
String providerLabel = saveAndComplete(session);
51+
String providerLabel = agentOnboardingProviders.getById((String) session.getAttribute("onboarding.provider")).getLabel();
52+
saveAndComplete(session);
5353
if (providerLabel != null) model.addAttribute("providerLabel", providerLabel);
5454
}
5555

@@ -72,7 +72,7 @@ public String getStep(@PathVariable String stepId, HttpSession session, Model mo
7272

7373
@PostMapping("/onboarding/{stepId}")
7474
public String postStep(@PathVariable String stepId, @RequestParam Map<String, String> formParams, HttpSession session, RedirectAttributes redirectAttrs) {
75-
OnboardingProvider provider = findProvider(stepId);
75+
OnboardingProvider provider = findOnboardingProvider(stepId);
7676
if (provider == null) {
7777
return "redirect:/onboarding/" + steps.getFirst().getStepId();
7878
}
@@ -88,17 +88,16 @@ public String postStep(@PathVariable String stepId, @RequestParam Map<String, St
8888

8989
String nextId = nextStepId(stepId);
9090
if (COMPLETE_STEP_ID.equals(nextId)) {
91-
String providerLabel = saveAndComplete(session);
91+
String providerLabel = agentOnboardingProviders.getById((String) session.getAttribute("onboarding.provider")).getLabel();
9292
if (providerLabel != null) redirectAttrs.addFlashAttribute("providerLabel", providerLabel);
93+
saveAndComplete(session);
9394
}
9495

9596
return "redirect:/onboarding/" + (nextId != null ? nextId : COMPLETE_STEP_ID);
9697
}
9798

98-
private String saveAndComplete(HttpSession session) {
99+
private void saveAndComplete(HttpSession session) {
99100
Map<String, Object> finalSession = sessionToMap(session);
100-
String providerId = (String) finalSession.getOrDefault("onboarding.provider", "");
101-
String providerLabel = SupportedProvider.from(providerId).map(SupportedProvider::label).orElse(null);
102101
try {
103102
for (OnboardingProvider p : steps) {
104103
p.saveConfiguration(finalSession, configurationManager);
@@ -107,10 +106,9 @@ private String saveAndComplete(HttpSession session) {
107106
throw new RuntimeException("Failed to save onboarding configuration", e);
108107
}
109108
clearOnboardingSession(session);
110-
return providerLabel;
111109
}
112110

113-
private OnboardingProvider findProvider(String stepId) {
111+
private OnboardingProvider findOnboardingProvider(String stepId) {
114112
return steps.stream()
115113
.filter(p -> p.getStepId().equals(stepId))
116114
.findFirst()

app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package ai.javaclaw.onboarding.steps;
22

3-
import ai.javaclaw.SupportedProvider;
43
import ai.javaclaw.configuration.ConfigurationManager;
4+
import ai.javaclaw.onboarding.AgentOnboardingProvider;
5+
import ai.javaclaw.onboarding.AgentOnboardingProviders;
56
import ai.javaclaw.onboarding.OnboardingProvider;
67
import org.springframework.core.annotation.Order;
78
import org.springframework.core.env.Environment;
@@ -19,9 +20,11 @@ public class S2_ProviderStep implements OnboardingProvider {
1920
static final String SESSION_MODEL = "onboarding.model";
2021
static final String SESSION_API_KEY = "onboarding.apiKey";
2122

23+
private final AgentOnboardingProviders agentOnboardingProviders;
2224
private final Environment env;
2325

24-
public S2_ProviderStep(Environment env) {
26+
public S2_ProviderStep(AgentOnboardingProviders agentOnboardingProviders, Environment env) {
27+
this.agentOnboardingProviders = agentOnboardingProviders;
2528
this.env = env;
2629
}
2730

@@ -36,7 +39,7 @@ public S2_ProviderStep(Environment env) {
3639

3740
@Override
3841
public void prepareModel(Map<String, Object> session, Map<String, Object> model) {
39-
model.put("providers", SupportedProvider.supportedAgents());
42+
model.put("providers", agentOnboardingProviders.getAll());
4043
model.put("selectedProvider", session.getOrDefault(SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")));
4144
}
4245

@@ -46,17 +49,14 @@ public String processStep(Map<String, String> formParams, Map<String, Object> se
4649
if (providerId == null || providerId.isBlank()) {
4750
return "Choose one of the supported providers to continue.";
4851
}
49-
SupportedProvider provider = SupportedProvider.from(providerId).orElse(null);
50-
if (provider == null) {
51-
return "Choose one of the supported providers to continue.";
52-
}
52+
AgentOnboardingProvider agentOnboardingProvider = agentOnboardingProviders.getById(providerId);
5353
// Clear downstream session state when provider changes
5454
String currentProvider = (String) session.get(SESSION_PROVIDER);
55-
if (!provider.id().equals(currentProvider)) {
55+
if (!agentOnboardingProvider.getId().equals(currentProvider)) {
5656
session.remove(SESSION_MODEL);
5757
session.remove(SESSION_API_KEY);
5858
}
59-
session.put(SESSION_PROVIDER, provider.id());
59+
session.put(SESSION_PROVIDER, agentOnboardingProvider.getId());
6060
return null;
6161
}
6262

@@ -66,13 +66,11 @@ public void saveConfiguration(Map<String, Object> session, ConfigurationManager
6666
String model = (String) session.get(SESSION_MODEL);
6767
String apiKey = (String) session.getOrDefault(SESSION_API_KEY, "");
6868

69-
SupportedProvider provider = SupportedProvider.from(providerId).orElse(null);
70-
if (provider == null) return;
71-
69+
AgentOnboardingProvider agentOnboardingProvider = agentOnboardingProviders.getById(providerId);
7270
Map<String, Object> props = new LinkedHashMap<>();
73-
provider.saveProperty(props, "chat.options.model", model);
74-
provider.saveProperty(props, "api-key", apiKey);
75-
props.put("spring.ai.model.chat", provider.id());
71+
agentOnboardingProvider.saveProperty(props, "chat.options.model", model);
72+
agentOnboardingProvider.saveProperty(props, "api-key", apiKey);
73+
props.put("spring.ai.model.chat", agentOnboardingProvider.getId().replace(".", "-"));
7674
configurationManager.updateProperties(props);
7775
}
7876
}

0 commit comments

Comments
 (0)