From dc6e9a6da819696562f7d750801af03f6f87e65d Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Thu, 2 Jul 2026 20:15:13 +0300 Subject: [PATCH 01/11] Add multi-model provider support and an agents page - Named LLM providers under `agent.llm.providers` (LlmProviderProperties) with a per-provider ChatModelFactory SPI (OpenAI, Anthropic incl. Claude Code OAuth, Ollama, Google Gen AI) and a ChatClientRegistry that builds/caches a ChatClient per provider and rebuilds on configuration refresh. - Hot-reload of configuration without a full restart (ConfigurationRebinder + ConfigurationRefreshedEvent; ConfigurationManager now tracks changed keys and supports removeProperty). - Main agent ChatClient delegates to the default provider and rebuilds on refresh (MainChatClientProvider + DelegatingChatClient); subagent routing wired via the agent-utils TaskTool. - Agents page + REST API (/api/agents): create/edit/delete agents; structured config (provider type, model, API key) is saved to application.yaml under agent.llm.providers., and description + instructions to workspace/agents/.md. - ProviderConnectionTester + /api/providers REST; onboarding now saves the selected provider as providers.default. - Tests for registry fallback, connection tester, and the agent/provider controllers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/ai/javaclaw/JavaClawApplication.java | 12 +- .../onboarding/steps/S2_ProviderStep.java | 17 +- .../providers/api/ProviderController.java | 179 ++++++++++ .../javaclaw/providers/api/ProviderDto.java | 21 ++ .../javaclaw/providers/api/ProviderForm.java | 19 ++ .../providers/api/SettingsController.java | 17 + .../providers/api/SubagentController.java | 231 +++++++++++++ .../main/resources/templates/chat.html.peb | 7 +- .../templates/settings/agents.html.peb | 315 ++++++++++++++++++ .../providers/api/ProviderControllerTest.java | 98 ++++++ .../providers/api/SubagentControllerTest.java | 138 ++++++++ .../ai/javaclaw/JavaClawConfiguration.java | 102 +----- .../ai/javaclaw/MainChatClientProvider.java | 201 +++++++++++ .../ConfigurationChangedEvent.java | 27 +- .../configuration/ConfigurationManager.java | 37 +- .../configuration/ConfigurationRebinder.java | 151 +++++++++ .../ConfigurationRefreshedEvent.java | 43 +++ .../ai/javaclaw/llm/ChatClientRegistry.java | 47 +++ .../ai/javaclaw/llm/ChatModelFactory.java | 28 ++ .../llm/DefaultChatClientRegistry.java | 147 ++++++++ .../ai/javaclaw/llm/DelegatingChatClient.java | 44 +++ .../ai/javaclaw/llm/LlmConfiguration.java | 9 + .../javaclaw/llm/LlmProviderProperties.java | 91 +++++ .../llm/SubagentReferenceScanner.java | 107 ++++++ .../java/ai/javaclaw/llm/SubagentStore.java | 117 +++++++ .../{TaskTool.java => JavaClawTaskTool.java} | 14 +- .../javaclaw/llm/ChatClientRegistryTest.java | 80 +++++ .../anthropic/AnthropicChatModelFactory.java | 62 ++++ .../genai/GoogleGenAiChatModelFactory.java | 40 +++ .../ollama/OllamaChatModelFactory.java | 40 +++ .../openai/OpenAiChatModelFactory.java | 36 ++ 31 files changed, 2376 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/ai/javaclaw/providers/api/ProviderController.java create mode 100644 app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java create mode 100644 app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java create mode 100644 app/src/main/java/ai/javaclaw/providers/api/SettingsController.java create mode 100644 app/src/main/java/ai/javaclaw/providers/api/SubagentController.java create mode 100644 app/src/main/resources/templates/settings/agents.html.peb create mode 100644 app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java create mode 100644 app/src/test/java/ai/javaclaw/providers/api/SubagentControllerTest.java create mode 100644 base/src/main/java/ai/javaclaw/MainChatClientProvider.java create mode 100644 base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java create mode 100644 base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java create mode 100644 base/src/main/java/ai/javaclaw/llm/ChatClientRegistry.java create mode 100644 base/src/main/java/ai/javaclaw/llm/ChatModelFactory.java create mode 100644 base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java create mode 100644 base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java create mode 100644 base/src/main/java/ai/javaclaw/llm/LlmConfiguration.java create mode 100644 base/src/main/java/ai/javaclaw/llm/LlmProviderProperties.java create mode 100644 base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java create mode 100644 base/src/main/java/ai/javaclaw/llm/SubagentStore.java rename base/src/main/java/ai/javaclaw/tools/{TaskTool.java => JavaClawTaskTool.java} (93%) create mode 100644 base/src/test/java/ai/javaclaw/llm/ChatClientRegistryTest.java create mode 100644 providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicChatModelFactory.java create mode 100644 providers/google/src/main/java/ai/javaclaw/providers/google/genai/GoogleGenAiChatModelFactory.java create mode 100644 providers/ollama/src/main/java/ai/javaclaw/providers/ollama/OllamaChatModelFactory.java create mode 100644 providers/openai/src/main/java/ai/javaclaw/providers/openai/OpenAiChatModelFactory.java diff --git a/app/src/main/java/ai/javaclaw/JavaClawApplication.java b/app/src/main/java/ai/javaclaw/JavaClawApplication.java index e396ce6c..09e92021 100644 --- a/app/src/main/java/ai/javaclaw/JavaClawApplication.java +++ b/app/src/main/java/ai/javaclaw/JavaClawApplication.java @@ -44,8 +44,18 @@ public void run(ApplicationArguments args) throws Exception { @EventListener public void on(ConfigurationChangedEvent configurationChangedEvent) { + // Model-provider changes are applied at runtime via ConfigurationRebinder + + // ChatClientRegistry (hot reload), so they must NOT trigger a full restart. + boolean providerChangeOnly = !configurationChangedEvent.changedKeys().isEmpty() + && configurationChangedEvent.changedKeys().stream() + .allMatch(key -> key.equals("agent.llm.providers") || key.startsWith("agent.llm.providers.")); + if (providerChangeOnly) { + log.info("Configuration change limited to model providers; applying without restart."); + return; + } + ApplicationArguments args = applicationContext.getBean(ApplicationArguments.class); - + Thread thread = new Thread(() -> { try { Thread.sleep(2000); diff --git a/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java b/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java index 7f978891..c4317344 100644 --- a/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java +++ b/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java @@ -1,6 +1,7 @@ package ai.javaclaw.onboarding.steps; import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.llm.LlmProviderProperties; import ai.javaclaw.onboarding.AgentOnboardingProvider; import ai.javaclaw.onboarding.AgentOnboardingProviders; import ai.javaclaw.onboarding.OnboardingProvider; @@ -63,14 +64,24 @@ public String processStep(Map formParams, Map se @Override public void saveConfiguration(Map session, ConfigurationManager configurationManager) throws IOException { String providerId = (String) session.get(SESSION_PROVIDER); + if (providerId == null || providerId.isBlank()) { + return; + } String model = (String) session.get(SESSION_MODEL); String apiKey = (String) session.getOrDefault(SESSION_API_KEY, ""); AgentOnboardingProvider agentOnboardingProvider = agentOnboardingProviders.getById(providerId); + + // The single provider configured during onboarding becomes the "default" named provider. + String base = "agent.llm.providers." + LlmProviderProperties.DEFAULT_PROVIDER_NAME; Map props = new LinkedHashMap<>(); - agentOnboardingProvider.saveProperty(props, "chat.options.model", model); - agentOnboardingProvider.saveProperty(props, "api-key", apiKey); - props.put("spring.ai.model.chat", agentOnboardingProvider.getId().replace(".", "-")); + props.put(base + ".provider", agentOnboardingProvider.getId()); + if (model != null && !model.isBlank()) { + props.put(base + ".model", model); + } + if (apiKey != null && !apiKey.isBlank()) { + props.put(base + ".api-key", apiKey); + } configurationManager.updateProperties(props); } } diff --git a/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java b/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java new file mode 100644 index 00000000..11ae00f1 --- /dev/null +++ b/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java @@ -0,0 +1,179 @@ +package ai.javaclaw.providers.api; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.llm.ChatModelFactory; +import ai.javaclaw.llm.LlmProviderProperties; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import ai.javaclaw.llm.SubagentReferenceScanner; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * REST API for managing the named LLM providers under {@code agent.llm.providers}. Saving fires a + * {@code ConfigurationChangedEvent}, which the {@code ConfigurationRebinder} turns into a + * {@code ConfigurationRefreshedEvent} so the {@code ChatClientRegistry} rebuilds affected clients. + */ +@RestController +@RequestMapping("/api/providers") +public class ProviderController { + + private static final Pattern NAME_PATTERN = Pattern.compile("[a-z0-9-]+"); + private static final String CONFIG_PREFIX = "agent.llm.providers"; + + private final LlmProviderProperties properties; + private final ConfigurationManager configurationManager; + private final SubagentReferenceScanner subagentScanner; + private final List chatModelFactories; + + public ProviderController(LlmProviderProperties properties, + ConfigurationManager configurationManager, + SubagentReferenceScanner subagentScanner, + List chatModelFactories) { + this.properties = properties; + this.configurationManager = configurationManager; + this.subagentScanner = subagentScanner; + this.chatModelFactories = chatModelFactories; + } + + private Map currentProviders() { + return properties.getProviders(); + } + + @GetMapping + public List list() { + List result = new ArrayList<>(); + currentProviders().forEach((name, config) -> result.add(toDto(name, config))); + return result; + } + + @PostMapping + public ResponseEntity create(@RequestBody ProviderForm form) { + String name = form.name() == null ? "" : form.name().trim(); + if (name.isBlank() || !NAME_PATTERN.matcher(name).matches()) { + return badRequest("Provider name must match [a-z0-9-]+"); + } + if (LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name)) { + return badRequest("The 'default' provider is created during onboarding and can only be edited"); + } + if (currentProviders().containsKey(name)) { + return badRequest("A provider named '" + name + "' already exists"); + } + if (!isKnownProviderType(form.provider())) { + return badRequest("Unknown provider type: " + form.provider()); + } + + save(name, form); + return ResponseEntity.status(HttpStatus.CREATED).body(toDto(name, form.toConfig())); + } + + @PutMapping("/{name}") + public ResponseEntity update(@PathVariable String name, @RequestBody ProviderForm form) { + ProviderConfig existing = currentProviders().get(name); + if (existing == null) { + return ResponseEntity.notFound().build(); + } + + // Name and provider type are immutable on edit. + ProviderForm effective = new ProviderForm(name, existing.getProvider(), form.model(), form.apiKey(), form.baseUrl()); + save(name, effective); + return ResponseEntity.ok(toDto(name, effective.toConfig())); + } + + @DeleteMapping("/{name}") + public ResponseEntity delete(@PathVariable String name, + @RequestParam(defaultValue = "false") boolean force) { + if (!currentProviders().containsKey(name)) { + return ResponseEntity.notFound().build(); + } + if (LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name)) { + return badRequest("The default provider cannot be removed"); + } + + Set referencedBy = subagentScanner.namesReferencing(name); + if (!referencedBy.isEmpty() && !force) { + Map body = new LinkedHashMap<>(); + body.put("message", "'" + name + "' is referenced by " + referencedBy.size() + " subagent(s)"); + body.put("affected", new ArrayList<>(referencedBy)); + return ResponseEntity.status(HttpStatus.CONFLICT).body(body); + } + + try { + configurationManager.removeProperty(CONFIG_PREFIX + "." + name); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(error("Failed to update configuration: " + e.getMessage())); + } + return ResponseEntity.noContent().build(); + } + + // --- helpers --- + + private void save(String name, ProviderForm form) { + String base = CONFIG_PREFIX + "." + name; + Map props = new LinkedHashMap<>(); + props.put(base + ".provider", form.provider()); + if (notBlank(form.model())) props.put(base + ".model", form.model()); + if (notBlank(form.apiKey())) props.put(base + ".api-key", form.apiKey()); + if (notBlank(form.baseUrl())) props.put(base + ".base-url", form.baseUrl()); + try { + configurationManager.updateProperties(props); + } catch (IOException e) { + throw new IllegalStateException("Failed to write provider configuration", e); + } + } + + private boolean isKnownProviderType(String type) { + if (type == null || type.isBlank()) { + return false; + } + String normalized = type.trim().toLowerCase(); + return chatModelFactories.stream().anyMatch(f -> f.supports(normalized)); + } + + private ProviderDto toDto(String name, ProviderConfig config) { + boolean isDefault = LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name); + return new ProviderDto(name, config.getProvider(), config.getModel(), + config.getBaseUrl(), maskApiKey(config.getApiKey()), isDefault); + } + + private static String maskApiKey(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + return ""; + } + String key = apiKey.trim(); + if (key.length() <= 7) { + return "••••"; + } + return key.substring(0, 3) + "..." + key.substring(key.length() - 4); + } + + private static boolean notBlank(String value) { + return value != null && !value.isBlank(); + } + + private static ResponseEntity> badRequest(String message) { + return ResponseEntity.badRequest().body(error(message)); + } + + private static Map error(String message) { + Map body = new LinkedHashMap<>(); + body.put("message", message); + return body; + } +} diff --git a/app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java b/app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java new file mode 100644 index 00000000..8126ec6b --- /dev/null +++ b/app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java @@ -0,0 +1,21 @@ +package ai.javaclaw.providers.api; + +/** + * Read model for a configured provider as returned by {@code GET /api/providers}. Never carries the + * raw API key — only a masked form for display. + * + * @param name provider name (the key under {@code agent.llm.providers}) + * @param provider provider type (openai | anthropic | ollama | google.genai | ...) + * @param model configured model id + * @param baseUrl base URL (relevant for ollama / self-hosted) + * @param apiKeyMasked masked API key for cloud providers, e.g. {@code sk-...a1b2}; empty otherwise + * @param isDefault whether this is the reserved {@code default} provider + */ +public record ProviderDto( + String name, + String provider, + String model, + String baseUrl, + String apiKeyMasked, + boolean isDefault) { +} diff --git a/app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java b/app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java new file mode 100644 index 00000000..af093059 --- /dev/null +++ b/app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java @@ -0,0 +1,19 @@ +package ai.javaclaw.providers.api; + +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; + +/** + * Write model for creating/updating a provider and for the standalone connection test. Carries the + * raw API key (request bodies only — never serialized back to the client). + */ +public record ProviderForm( + String name, + String provider, + String model, + String apiKey, + String baseUrl) { + + public ProviderConfig toConfig() { + return new ProviderConfig(provider, apiKey, baseUrl, model); + } +} diff --git a/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java b/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java new file mode 100644 index 00000000..5bb234f0 --- /dev/null +++ b/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java @@ -0,0 +1,17 @@ +package ai.javaclaw.providers.api; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Serves the post-onboarding settings pages. The Subagents page is a thin shell that talks to + * {@link SubagentController} (and reads provider names from {@link ProviderController}) via fetch. + */ +@Controller +public class SettingsController { + + @GetMapping("/settings/agents") + public String agents() { + return "settings/agents"; + } +} diff --git a/app/src/main/java/ai/javaclaw/providers/api/SubagentController.java b/app/src/main/java/ai/javaclaw/providers/api/SubagentController.java new file mode 100644 index 00000000..2b18d379 --- /dev/null +++ b/app/src/main/java/ai/javaclaw/providers/api/SubagentController.java @@ -0,0 +1,231 @@ +package ai.javaclaw.providers.api; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.llm.LlmProviderProperties; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import ai.javaclaw.llm.SubagentStore; +import ai.javaclaw.llm.SubagentStore.Subagent; +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import ai.javaclaw.onboarding.AgentOnboardingProviders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * REST API for the agents page. Each agent's structured configuration (provider type, model, API key) + * is stored in {@code application.yaml} under {@code agent.llm.providers.}, and its description + + * instructions are stored in {@code workspace/agents/.md}. The agent's subagent file routes to + * its own provider entry (its {@code model:} frontmatter is the agent name), so it runs on its own + * provider/model/key. Provider options for the dropdown come from the supported + * {@link AgentOnboardingProvider} beans. + */ +@RestController +@RequestMapping("/api/agents") +public class SubagentController { + + private static final Pattern NAME_PATTERN = Pattern.compile("[a-z0-9-]+"); + private static final String PROVIDERS_PREFIX = "agent.llm.providers"; + + private final SubagentStore store; + private final AgentOnboardingProviders providers; + private final LlmProviderProperties providerProperties; + private final ConfigurationManager configurationManager; + + public SubagentController(SubagentStore store, + AgentOnboardingProviders providers, + LlmProviderProperties providerProperties, + ConfigurationManager configurationManager) { + this.store = store; + this.providers = providers; + this.providerProperties = providerProperties; + this.configurationManager = configurationManager; + } + + public record AgentSummary(String name, String provider, String providerLabel, String model, String description) { + } + + public record AgentDetail(String name, String provider, String providerLabel, String model, String apiKeyMasked, + String description, String content) { + } + + /** Request body for create/update. */ + public record AgentForm(String name, String provider, String model, String apiKey, String description, + String content) { + } + + /** A selectable LLM provider for the dropdown. */ + public record ProviderOption(String id, String label, String defaultModel) { + } + + @GetMapping("/options/providers") + public List providerOptions() { + return providers.getAll().stream() + .map(p -> new ProviderOption(p.getId(), p.getLabel(), p.defaultModel())) + .toList(); + } + + @GetMapping + public List list() { + return store.list().stream().map(s -> { + ProviderConfig config = providerProperties.getProviders().get(s.name()); + String provider = config != null ? config.getProvider() : null; + String model = config != null ? config.getModel() : null; + return new AgentSummary(s.name(), provider, labelFor(provider), model, s.description()); + }).toList(); + } + + @GetMapping("/{name}") + public ResponseEntity get(@PathVariable String name) { + if (!isValidName(name)) { + return ResponseEntity.notFound().build(); + } + return store.get(name) + .map(s -> { + ProviderConfig config = providerProperties.getProviders().get(name); + String provider = config != null ? config.getProvider() : null; + String model = config != null ? config.getModel() : null; + String maskedKey = config != null ? maskApiKey(config.getApiKey()) : ""; + return ResponseEntity.ok(new AgentDetail(s.name(), provider, labelFor(provider), model, + maskedKey, s.description(), s.content())); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity create(@RequestBody AgentForm form) { + String name = form.name() == null ? "" : form.name().trim(); + if (name.isBlank() || !NAME_PATTERN.matcher(name).matches()) { + return badRequest("Agent name must match [a-z0-9-]+"); + } + if (providerProperties.getProviders().containsKey(name) || store.exists(name)) { + return badRequest("An agent named '" + name + "' already exists"); + } + String error = validateProvider(form.provider()); + if (error != null) { + return badRequest(error); + } + return save(name, form, HttpStatus.CREATED); + } + + @PutMapping("/{name}") + public ResponseEntity update(@PathVariable String name, @RequestBody AgentForm form) { + if (!isValidName(name) || !store.exists(name)) { + return ResponseEntity.notFound().build(); + } + String error = validateProvider(form.provider()); + if (error != null) { + return badRequest(error); + } + return save(name, form, HttpStatus.OK); + } + + @DeleteMapping("/{name}") + public ResponseEntity delete(@PathVariable String name) { + if (!isValidName(name)) { + return ResponseEntity.notFound().build(); + } + try { + boolean removedFile = store.delete(name); + boolean removedConfig = providerProperties.getProviders().containsKey(name); + if (!removedFile && !removedConfig) { + return ResponseEntity.notFound().build(); + } + // Removing the provider entry fires a configuration change that rebuilds routing. + configurationManager.removeProperty(PROVIDERS_PREFIX + "." + name); + return ResponseEntity.noContent().build(); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(error("Failed to delete agent: " + e.getMessage())); + } + } + + private ResponseEntity save(String name, AgentForm form, HttpStatus status) { + try { + // 1) Instructions + description -> workspace/agents/.md. The subagent routes to its + // own provider entry, so its routing model is the agent name. + store.save(new Subagent(name, name, form.description(), form.content())); + + // 2) Structured config -> application.yaml under agent.llm.providers.. This fires a + // configuration change that rebuilds the chat client registry + subagent routing. + String base = PROVIDERS_PREFIX + "." + name; + Map props = new LinkedHashMap<>(); + props.put(base + ".provider", form.provider()); + if (notBlank(form.model())) { + props.put(base + ".model", form.model().trim()); + } + if (notBlank(form.apiKey())) { + props.put(base + ".api-key", form.apiKey().trim()); + } + configurationManager.updateProperties(props); + + ProviderConfig saved = providerProperties.getProviders().get(name); + String maskedKey = saved != null ? maskApiKey(saved.getApiKey()) : maskApiKey(form.apiKey()); + return ResponseEntity.status(status).body(new AgentDetail(name, form.provider(), labelFor(form.provider()), + orEmpty(form.model()), maskedKey, orEmpty(form.description()), orEmpty(form.content()))); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(error("Failed to save agent: " + e.getMessage())); + } + } + + private String validateProvider(String provider) { + if (provider == null || provider.isBlank()) { + return "A provider is required"; + } + if (providers.findById(provider).isEmpty()) { + return "Unknown provider: " + provider; + } + return null; + } + + private boolean isValidName(String name) { + return name != null && NAME_PATTERN.matcher(name).matches(); + } + + private String labelFor(String providerId) { + if (providerId == null || providerId.isBlank()) { + return providerId; + } + return providers.findById(providerId).map(AgentOnboardingProvider::getLabel).orElse(providerId); + } + + private static String maskApiKey(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + return ""; + } + String key = apiKey.trim(); + if (key.length() <= 7) { + return "••••"; + } + return key.substring(0, 3) + "..." + key.substring(key.length() - 4); + } + + private static boolean notBlank(String value) { + return value != null && !value.isBlank(); + } + + private static String orEmpty(String value) { + return value == null ? "" : value; + } + + private static ResponseEntity> badRequest(String message) { + return ResponseEntity.badRequest().body(error(message)); + } + + private static Map error(String message) { + Map body = new LinkedHashMap<>(); + body.put("message", message); + return body; + } +} diff --git a/app/src/main/resources/templates/chat.html.peb b/app/src/main/resources/templates/chat.html.peb index 4728c84f..963eef98 100644 --- a/app/src/main/resources/templates/chat.html.peb +++ b/app/src/main/resources/templates/chat.html.peb @@ -168,7 +168,12 @@
+ diff --git a/app/src/main/resources/templates/settings/agents.html.peb b/app/src/main/resources/templates/settings/agents.html.peb new file mode 100644 index 00000000..97656adc --- /dev/null +++ b/app/src/main/resources/templates/settings/agents.html.peb @@ -0,0 +1,315 @@ +{% extends "../base" %} +{% block title %} · Agents{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} + + +
+
+
+
+

Agents

+

Agents the main assistant can delegate to. Each runs on a provider and model.

+
+
+
+ +
+
+ + + +
+
+ +
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java b/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java new file mode 100644 index 00000000..cb5a0e7a --- /dev/null +++ b/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java @@ -0,0 +1,98 @@ +package ai.javaclaw.providers.api; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.llm.ChatModelFactory; +import ai.javaclaw.llm.LlmProviderProperties; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import ai.javaclaw.llm.SubagentReferenceScanner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ProviderController.class) +class ProviderControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LlmProviderProperties properties; + + @MockitoBean + private ConfigurationManager configurationManager; + + @MockitoBean + private SubagentReferenceScanner subagentScanner; + + @MockitoBean + private ChatModelFactory chatModelFactory; + + @BeforeEach + void setUp() { + when(chatModelFactory.supports("openai")).thenReturn(true); + } + + @Test + void createPersistsProvider() throws Exception { + when(properties.getProviders()).thenReturn(Map.of()); + + mockMvc.perform(post("/api/providers") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"local\",\"provider\":\"openai\",\"model\":\"gpt-4o\",\"apiKey\":\"sk-test\"}")) + .andExpect(status().isCreated()); + + verify(configurationManager).updateProperties(any()); + } + + @Test + void createRejectsUnknownProviderType() throws Exception { + when(properties.getProviders()).thenReturn(Map.of()); + + mockMvc.perform(post("/api/providers") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"local\",\"provider\":\"ghost\",\"model\":\"x\",\"apiKey\":\"k\"}")) + .andExpect(status().isBadRequest()); + + verify(configurationManager, never()).updateProperties(any()); + } + + @Test + void deleteReturnsConflictWhenReferencedBySubagents() throws Exception { + when(properties.getProviders()).thenReturn(Map.of("local", new ProviderConfig("openai", "k", null, "gpt-4o"))); + when(subagentScanner.namesReferencing("local")).thenReturn(Set.of("summariser", "classifier")); + + mockMvc.perform(delete("/api/providers/local")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.affected").isArray()) + .andExpect(jsonPath("$.affected.length()").value(2)); + + verify(configurationManager, never()).removeProperty(any()); + } + + @Test + void deleteWithForceSucceedsEvenWhenReferenced() throws Exception { + when(properties.getProviders()).thenReturn(Map.of("local", new ProviderConfig("openai", "k", null, "gpt-4o"))); + when(subagentScanner.namesReferencing("local")).thenReturn(Set.of("summariser")); + + mockMvc.perform(delete("/api/providers/local").param("force", "true")) + .andExpect(status().isNoContent()); + + verify(configurationManager).removeProperty("agent.llm.providers.local"); + } +} diff --git a/app/src/test/java/ai/javaclaw/providers/api/SubagentControllerTest.java b/app/src/test/java/ai/javaclaw/providers/api/SubagentControllerTest.java new file mode 100644 index 00000000..516ae91b --- /dev/null +++ b/app/src/test/java/ai/javaclaw/providers/api/SubagentControllerTest.java @@ -0,0 +1,138 @@ +package ai.javaclaw.providers.api; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.llm.LlmProviderProperties; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import ai.javaclaw.llm.SubagentStore; +import ai.javaclaw.llm.SubagentStore.Subagent; +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import ai.javaclaw.onboarding.AgentOnboardingProviders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(SubagentController.class) +class SubagentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private SubagentStore store; + + @MockitoBean + private AgentOnboardingProviders providers; + + @MockitoBean + private LlmProviderProperties providerProperties; + + @MockitoBean + private ConfigurationManager configurationManager; + + @BeforeEach + void setUp() { + AgentOnboardingProvider openai = Mockito.mock(AgentOnboardingProvider.class); + when(openai.getId()).thenReturn("openai"); + when(openai.getLabel()).thenReturn("OpenAI"); + when(openai.defaultModel()).thenReturn("gpt-4o"); + when(providers.getAll()).thenReturn(List.of(openai)); + when(providers.findById("openai")).thenReturn(Optional.of(openai)); + when(providers.findById("ghost")).thenReturn(Optional.empty()); + when(providerProperties.getProviders()).thenReturn(new java.util.LinkedHashMap<>()); + } + + @Test + void providerOptionsComeFromOnboardingProviders() throws Exception { + mockMvc.perform(get("/api/agents/options/providers")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("openai")) + .andExpect(jsonPath("$[0].label").value("OpenAI")) + .andExpect(jsonPath("$[0].defaultModel").value("gpt-4o")); + } + + @Test + void listMergesProviderConfigWithMdDescription() throws Exception { + when(store.list()).thenReturn(List.of(new Subagent("summariser", "summariser", "Summarises", "body"))); + when(providerProperties.getProviders()).thenReturn(Map.of( + "summariser", new ProviderConfig("openai", "sk-test", null, "gpt-4o"))); + + mockMvc.perform(get("/api/agents")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("summariser")) + .andExpect(jsonPath("$[0].provider").value("openai")) + .andExpect(jsonPath("$[0].providerLabel").value("OpenAI")) + .andExpect(jsonPath("$[0].model").value("gpt-4o")); + } + + @Test + void createWritesMdFileAndProviderConfig() throws Exception { + when(store.exists("summariser")).thenReturn(false); + + mockMvc.perform(post("/api/agents") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"summariser\",\"provider\":\"openai\",\"model\":\"gpt-4o\",\"apiKey\":\"sk-secret\",\"description\":\"sums\",\"content\":\"do it\"}")) + .andExpect(status().isCreated()); + + verify(store).save(any(Subagent.class)); + // Structured fields (provider/model/api-key) are persisted to agent.llm.providers.summariser. + verify(configurationManager).updateProperties(argThat(m -> + "openai".equals(m.get("agent.llm.providers.summariser.provider")) + && "gpt-4o".equals(m.get("agent.llm.providers.summariser.model")) + && "sk-secret".equals(m.get("agent.llm.providers.summariser.api-key")))); + } + + @Test + void createRejectsUnknownProvider() throws Exception { + when(store.exists("summariser")).thenReturn(false); + + mockMvc.perform(post("/api/agents") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"summariser\",\"provider\":\"ghost\",\"content\":\"x\"}")) + .andExpect(status().isBadRequest()); + + verify(store, never()).save(any()); + } + + @Test + void createRejectsNameAlreadyUsedByAProvider() throws Exception { + when(providerProperties.getProviders()).thenReturn(Map.of( + "default", new ProviderConfig("openai", "k", null, "gpt-4o"))); + + mockMvc.perform(post("/api/agents") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"default\",\"provider\":\"openai\",\"content\":\"x\"}")) + .andExpect(status().isBadRequest()); + + verify(store, never()).save(any()); + } + + @Test + void deleteRemovesMdAndProviderConfig() throws Exception { + when(store.delete("summariser")).thenReturn(true); + + mockMvc.perform(delete("/api/agents/summariser")) + .andExpect(status().isNoContent()); + + verify(configurationManager).removeProperty("agent.llm.providers.summariser"); + } +} diff --git a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java index ac1fb5cb..33910c87 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -1,20 +1,9 @@ package ai.javaclaw; -import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.llm.DelegatingChatClient; import ai.javaclaw.tasks.TaskManager; -import ai.javaclaw.tools.AgentEnvironment; -import ai.javaclaw.tools.AutoDiscoveredTool; -import ai.javaclaw.tools.CheckListTool; -import ai.javaclaw.tools.McpTool; -import ai.javaclaw.tools.TaskTool; -import org.springaicommunity.agent.tools.FileSystemTools; -import org.springaicommunity.agent.tools.SkillsTool; -import org.springaicommunity.agent.tools.SmartWebFetchTool; +import ai.javaclaw.tools.JavaClawTaskTool; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; -import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; -import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor; -import org.springframework.ai.chat.client.advisor.toolsearch.ToolSearchToolCallingAdvisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.memory.MessageWindowChatMemory; @@ -22,36 +11,27 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; import org.springframework.ai.model.SpringAIModelProperties; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; -import org.springframework.core.io.Resource; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; -import java.util.Set; @Configuration public class JavaClawConfiguration { public static final String AGENT_MD = "AGENT.private.md"; - @Value("${agent.skills.paths}") - List skillPaths; - + private static final String NO_MODEL_MESSAGE = + "No AI model has been configured. If you did configure a model recently, restart JavaClaw " + + "manually for the changes to take effect."; @Bean @ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = "unknown", matchIfMissing = true) public ChatModel chatModel() { - return prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage("No AI model has been configured. If you did configure a model recently, restart JavaClaw manually for the changes to take effect.")))); + return _ -> new ChatResponse(List.of(new Generation(new AssistantMessage(NO_MODEL_MESSAGE)))); } @Bean @@ -60,71 +40,25 @@ public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) { } @Bean - public TaskTool taskTool(TaskManager taskManager) { - return TaskTool.builder().taskManager(taskManager).build(); + public JavaClawTaskTool taskTool(TaskManager taskManager) { + return JavaClawTaskTool.builder().taskManager(taskManager).build(); } @Bean public ChatClient.Builder chatClientBuilder(ObjectProvider chatModelProvider) { - ChatModel chatModel = chatModelProvider.getIfUnique(() -> prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage("No AI model has been configured. If you did configure a model recently, restart JavaClaw manually for the changes to take effect."))))); + ChatModel chatModel = chatModelProvider.getIfUnique(() -> + prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage(NO_MODEL_MESSAGE))))); return ChatClient.builder(chatModel); } + /** + * The main agent {@link ChatClient}. Delegates to {@link MainChatClientProvider}, which builds it + * from the {@code default} provider in {@code agent.llm.providers} and rebuilds it on a + * configuration refresh — so the single injected reference always reflects the current default + * provider without restarting the application. + */ @Bean - @DependsOn({"mcpHeaderCustomizer"}) - public ChatClient chatClient(ChatClient.Builder chatClientBuilder, - ChatMemory chatMemory, - ObjectProvider toolSearchToolCallAdvisorProvider, - SyncMcpToolCallbackProvider mcpToolProvider, - TaskManager taskManager, - ConfigurationManager configurationManager, - @Value("${agent.workspace:Unknown}") Resource workspace, - Set> autoDiscoveredTools - ) throws IOException { - - Resource agentMd = workspace.createRelative(AGENT_MD); - if (!agentMd.exists()) { - agentMd = workspace.createRelative("AGENT.md"); - } - String agentPrompt = agentMd.getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator() - + workspace.createRelative("INFO.md").getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator(); - - ToolCallingAdvisor toolCallAdvisor = toolSearchToolCallAdvisorProvider.getIfAvailable(); - if (toolCallAdvisor == null) { - toolCallAdvisor = ToolCallingAdvisor.builder().build(); - } - - chatClientBuilder - .defaultAdvisors(new SimpleLoggerAdvisor()) - .defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info())) - .defaultTools((Object[]) mcpToolProvider.getToolCallbacks()) - .defaultTools(SkillsTool.builder() - .addSkillsDirectory(skillsDir(workspace).toString()) - .addSkillsResources(skillPaths) - .build() - ) - .defaultTools( - TaskTool.builder().taskManager(taskManager).build(), - CheckListTool.builder().build(), - McpTool.builder().configurationManager(configurationManager).build(), - //Bash execution tool - //ShellTools.builder().build(),// built-in shell tools - // Read, Write and Edit files tool - FileSystemTools.builder().build(),// built-in file system tools - // Smart web fetch tool - SmartWebFetchTool.builder(chatClientBuilder.clone().build()).build()) - .defaultAdvisors( - toolCallAdvisor, - MessageChatMemoryAdvisor.builder(chatMemory).build() - ); - - autoDiscoveredTools.forEach(autoDiscoveredTool -> chatClientBuilder.defaultTools(autoDiscoveredTool.tool())); - return chatClientBuilder.build(); - } - - private static Path skillsDir(Resource workspace) throws IOException { - Path skillsDir = workspace.getFilePath().resolve("skills"); - Files.createDirectories(skillsDir); - return skillsDir; + public ChatClient chatClient(MainChatClientProvider mainChatClientProvider) { + return new DelegatingChatClient(mainChatClientProvider::current); } } diff --git a/base/src/main/java/ai/javaclaw/MainChatClientProvider.java b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java new file mode 100644 index 00000000..270c4d64 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java @@ -0,0 +1,201 @@ +package ai.javaclaw; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.configuration.ConfigurationRefreshedEvent; +import ai.javaclaw.llm.ChatClientRegistry; +import ai.javaclaw.llm.LlmProviderProperties; +import ai.javaclaw.llm.SubagentReferenceScanner; +import ai.javaclaw.tasks.TaskManager; +import ai.javaclaw.tools.AgentEnvironment; +import ai.javaclaw.tools.AutoDiscoveredTool; +import ai.javaclaw.tools.CheckListTool; +import ai.javaclaw.tools.McpTool; +import ai.javaclaw.tools.JavaClawTaskTool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.agent.common.task.subagent.SubagentType; +import org.springaicommunity.agent.tools.FileSystemTools; +import org.springaicommunity.agent.tools.SkillsTool; +import org.springaicommunity.agent.tools.SmartWebFetchTool; +import org.springaicommunity.agent.tools.task.TaskTool; +import org.springaicommunity.agent.tools.task.claude.ClaudeSubagentReferences; +import org.springaicommunity.agent.tools.task.claude.ClaudeSubagentType; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor; +import org.springframework.ai.chat.client.advisor.toolsearch.ToolSearchToolCallingAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.event.EventListener; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Builds and holds the main agent {@link ChatClient} (default provider + all tools, advisors and + * subagent routing). Rebuilt when the provider configuration is hot-reloaded so changes to the + * {@code default} provider and the set of subagent providers take effect without restarting. + */ +@Component +@DependsOn({"mcpHeaderCustomizer"}) +public class MainChatClientProvider { + + private static final Logger log = LoggerFactory.getLogger(MainChatClientProvider.class); + private static final String DEFAULT = LlmProviderProperties.DEFAULT_PROVIDER_NAME; + + private final ChatClientRegistry registry; + private final ChatMemory chatMemory; + private final ObjectProvider toolSearchAdvisorProvider; + private final SyncMcpToolCallbackProvider mcpToolProvider; + private final TaskManager taskManager; + private final ConfigurationManager configurationManager; + private final SubagentReferenceScanner subagentScanner; + private final LlmProviderProperties providerProperties; + private final Set> autoDiscoveredTools; + private final Resource workspace; + private final List skillPaths; + + private volatile ChatClient delegate; + + public MainChatClientProvider(ChatClientRegistry registry, + ChatMemory chatMemory, + ObjectProvider toolSearchAdvisorProvider, + SyncMcpToolCallbackProvider mcpToolProvider, + TaskManager taskManager, + ConfigurationManager configurationManager, + SubagentReferenceScanner subagentScanner, + LlmProviderProperties providerProperties, + Set> autoDiscoveredTools, + @Value("${agent.workspace:Unknown}") Resource workspace, + @Value("${agent.skills.paths}") List skillPaths) { + this.registry = registry; + this.chatMemory = chatMemory; + this.toolSearchAdvisorProvider = toolSearchAdvisorProvider; + this.mcpToolProvider = mcpToolProvider; + this.taskManager = taskManager; + this.configurationManager = configurationManager; + this.subagentScanner = subagentScanner; + this.providerProperties = providerProperties; + this.autoDiscoveredTools = autoDiscoveredTools; + this.workspace = workspace; + this.skillPaths = skillPaths; + this.delegate = build(); + } + + /** The current main {@link ChatClient}. Accessed via {@link ai.javaclaw.llm.DelegatingChatClient}. */ + public ChatClient current() { + return delegate; + } + + @EventListener + public void onConfigurationRefreshed(ConfigurationRefreshedEvent event) { + if (event.changedKeys().isEmpty() || event.hasChangeUnder("agent.llm.providers")) { + log.info("Rebuilding main chat client after configuration refresh"); + this.delegate = build(); + } + } + + private ChatClient build() { + try { + ChatClient.Builder builder = registry.builderFor(DEFAULT); + + String agentPrompt = readSystemPrompt(); + ToolCallingAdvisor toolCallAdvisor = toolSearchAdvisorProvider.getIfAvailable(); + if (toolCallAdvisor == null) { + toolCallAdvisor = ToolCallingAdvisor.builder().build(); + } + + builder.defaultAdvisors(new SimpleLoggerAdvisor()) + //.defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info())) + .defaultTools((Object[]) mcpToolProvider.getToolCallbacks()) + .defaultTools(SkillsTool.builder() + .addSkillsDirectory(skillsDir().toString()) + .addSkillsResources(skillPaths) + .build()) + .defaultTools( + JavaClawTaskTool.builder().taskManager(taskManager).build(), + CheckListTool.builder().build(), + McpTool.builder().configurationManager(configurationManager).build(), + FileSystemTools.builder().build(), + SmartWebFetchTool.builder(registry.getOrDefault(DEFAULT)).build()) + .defaultAdvisors(toolCallAdvisor, MessageChatMemoryAdvisor.builder(chatMemory).build()); + + ToolCallback subagentTaskTool = buildSubagentTaskTool(); + if (subagentTaskTool != null) { + builder.defaultTools(subagentTaskTool); + } + + autoDiscoveredTools.forEach(tool -> builder.defaultTools(tool.tool())); + return builder.build(); + } catch (IOException e) { + throw new IllegalStateException("Failed to build the main chat client", e); + } + } + + /** + * Builds the agent-utils subagent dispatch tool: a Claude subagent type backed by a per-provider + * {@link ChatClient.Builder} map, with subagent definitions discovered from + * {@code workspace/agents/}. + */ + private ToolCallback buildSubagentTaskTool() { + Path agentsDir = subagentScanner.agentsDirectory(); + if (agentsDir == null) { + return null; + } + try { + Files.createDirectories(agentsDir); + + Map builders = new LinkedHashMap<>(); + for (String name : registry.availableNames()) { + builders.put(name, registry.builderFor(name)); + LlmProviderProperties.ProviderConfig config = providerProperties.getProviders().get(name); + if (config != null && config.getProvider() != null && !config.getProvider().isBlank()) { + builders.putIfAbsent(config.getProvider().trim().toLowerCase(), registry.builderFor(name)); + } + } + if (builders.isEmpty()) { + return null; + } + + SubagentType claudeType = ClaudeSubagentType.builder() + .chatClientBuilders(builders) + .skillsDirectories(skillsDir().toString()) + .build(); + return TaskTool.builder() + .subagentReferences(ClaudeSubagentReferences.fromRootDirectory(agentsDir.toString())) + .subagentTypes(claudeType) + .build(); + } catch (IOException e) { + log.warn("Failed to build subagent task tool: {}", e.getMessage()); + return null; + } + } + + private String readSystemPrompt() throws IOException { + Resource agentMd = workspace.createRelative(JavaClawConfiguration.AGENT_MD); + if (!agentMd.exists()) { + agentMd = workspace.createRelative("AGENT.md"); + } + return agentMd.getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator() + + workspace.createRelative("INFO.md").getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator(); + } + + private Path skillsDir() throws IOException { + Path skillsDir = workspace.getFilePath().resolve("skills"); + Files.createDirectories(skillsDir); + return skillsDir; + } +} diff --git a/base/src/main/java/ai/javaclaw/configuration/ConfigurationChangedEvent.java b/base/src/main/java/ai/javaclaw/configuration/ConfigurationChangedEvent.java index 1559a279..3285c6ae 100644 --- a/base/src/main/java/ai/javaclaw/configuration/ConfigurationChangedEvent.java +++ b/base/src/main/java/ai/javaclaw/configuration/ConfigurationChangedEvent.java @@ -1,8 +1,27 @@ package ai.javaclaw.configuration; import java.util.Map; +import java.util.Set; -public record ConfigurationChangedEvent(Map allConfig) { +/** + * Published by {@link ConfigurationManager} whenever the configuration file is written. + * + * @param allConfig the full configuration tree as it was written to disk + * @param changedKeys the fully-qualified (dotted) keys that were updated in this write, + * e.g. {@code "agent.llm.providers.default.model"} + */ +public record ConfigurationChangedEvent(Map allConfig, Set changedKeys) { + + public ConfigurationChangedEvent { + changedKeys = changedKeys == null ? Set.of() : Set.copyOf(changedKeys); + } + + /** + * Backwards-compatible constructor for callers that only have the full configuration tree. + */ + public ConfigurationChangedEvent(Map allConfig) { + this(allConfig, Set.of()); + } public Object getConfiguration(String key) { String[] keys = key.split("\\."); @@ -17,4 +36,10 @@ else if (configItem instanceof Map nestedMap) { return map.get(keys[keys.length - 1]); } + /** + * Whether any changed key equals or is nested under the given prefix. + */ + public boolean hasChangeUnder(String prefix) { + return changedKeys.stream().anyMatch(key -> key.equals(prefix) || key.startsWith(prefix + ".")); + } } diff --git a/base/src/main/java/ai/javaclaw/configuration/ConfigurationManager.java b/base/src/main/java/ai/javaclaw/configuration/ConfigurationManager.java index 884f4197..2c58f581 100644 --- a/base/src/main/java/ai/javaclaw/configuration/ConfigurationManager.java +++ b/base/src/main/java/ai/javaclaw/configuration/ConfigurationManager.java @@ -14,6 +14,7 @@ import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; @Component public class ConfigurationManager { @@ -31,7 +32,7 @@ public void updateProperty(String key, Object value) throws IOException { setNestedValue(config, key.split("\\."), value); - writeApplicationYaml(config); + writeApplicationYaml(config, Set.of(key)); } public void updateProperties(Map keyValues) throws IOException { @@ -39,7 +40,19 @@ public void updateProperties(Map keyValues) throws IOException { keyValues.forEach((k, v) -> setNestedValue(config, k.split("\\."), v)); - writeApplicationYaml(config); + writeApplicationYaml(config, keyValues.keySet()); + } + + /** + * Removes a nested key (and writes the file). Used when deleting configuration sub-trees, + * e.g. {@code agent.llm.providers.local}. + */ + public void removeProperty(String key) throws IOException { + Map config = readApplicationYaml(); + + removeNestedValue(config, key.split("\\.")); + + writeApplicationYaml(config, Set.of(key)); } public Map readApplicationYaml() throws IOException { @@ -55,7 +68,7 @@ public Map readApplicationYaml() throws IOException { return config == null ? new LinkedHashMap<>() : config; } - private void writeApplicationYaml(Map config) throws IOException { + private void writeApplicationYaml(Map config, Set changedKeys) throws IOException { DumperOptions options = new DumperOptions(); options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); options.setPrettyFlow(true); @@ -67,7 +80,11 @@ private void writeApplicationYaml(Map config) throws IOException writer.dump(config, fw); } - eventPublisher.publishEvent(new ConfigurationChangedEvent(config)); + eventPublisher.publishEvent(new ConfigurationChangedEvent(config, changedKeys)); + } + + public Path getConfigPath() { + return configPath; } private Path resolveConfigPath(String location) { @@ -92,4 +109,16 @@ private void setNestedValue(Map map, String[] keys, Object value } map.put(keys[keys.length - 1], value); } + + @SuppressWarnings("unchecked") + private void removeNestedValue(Map map, String[] keys) { + for (int i = 0; i < keys.length - 1; i++) { + Object next = map.get(keys[i]); + if (!(next instanceof Map nested)) { + return; // path does not exist; nothing to remove + } + map = (Map) nested; + } + map.remove(keys[keys.length - 1]); + } } diff --git a/base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java b/base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java new file mode 100644 index 00000000..662cee38 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java @@ -0,0 +1,151 @@ +package ai.javaclaw.configuration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.event.EventListener; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Makes runtime configuration changes take effect without restarting the whole Spring context. + *

+ * When the configuration file changes ({@link ConfigurationChangedEvent}) this component: + *

    + *
  1. reloads the file into the running {@link ConfigurableEnvironment} with the highest + * precedence, so the new values shadow the stale startup values;
  2. + *
  3. rebinds every {@code @ConfigurationProperties} bean from the refreshed environment + * (immutable / record-based beans are re-instantiated and swapped in the bean factory);
  4. + *
  5. publishes a {@link ConfigurationRefreshedEvent} so beans that hold runtime state + * (channels, the agent's chat client, ...) can re-initialise themselves.
  6. + *
+ * This is a deliberately lightweight stand-in for Spring Cloud Context's + * {@code ConfigurationPropertiesRebinder}, which is not on the classpath. + */ +@Component +public class ConfigurationRebinder { + + private static final Logger log = LoggerFactory.getLogger(ConfigurationRebinder.class); + + /** Name of the property source we (re)inject on every reload. */ + static final String RUNTIME_PROPERTY_SOURCE = "javaclaw-runtime-config"; + + private final ConfigurableApplicationContext applicationContext; + private final ConfigurableEnvironment environment; + private final ConfigurationManager configurationManager; + private final ApplicationEventPublisher eventPublisher; + + public ConfigurationRebinder(ConfigurableApplicationContext applicationContext, + ConfigurableEnvironment environment, + ConfigurationManager configurationManager, + ApplicationEventPublisher eventPublisher) { + this.applicationContext = applicationContext; + this.environment = environment; + this.configurationManager = configurationManager; + this.eventPublisher = eventPublisher; + } + + @EventListener + public void onConfigurationChanged(ConfigurationChangedEvent event) { + rebind(event.changedKeys()); + } + + /** + * Reloads the environment, rebinds all {@code @ConfigurationProperties} beans and announces the + * refresh. Safe to call directly (e.g. from tests). + */ + public synchronized void rebind(Set changedKeys) { + reloadEnvironment(); + rebindConfigurationProperties(); + log.info("Configuration reloaded and rebound ({} changed key(s)): {}", changedKeys.size(), changedKeys); + eventPublisher.publishEvent(new ConfigurationRefreshedEvent(this, changedKeys)); + } + + private void reloadEnvironment() { + Path path = configurationManager.getConfigPath(); + if (path == null || !Files.exists(path)) { + log.debug("No configuration file at {} to reload", path); + return; + } + + try { + List> loaded = + new YamlPropertySourceLoader().load(RUNTIME_PROPERTY_SOURCE, new FileSystemResource(path)); + + MutablePropertySources sources = environment.getPropertySources(); + // Drop the previously injected runtime source(s) before re-adding the fresh ones. + sources.stream() + .map(PropertySource::getName) + .filter(name -> name.startsWith(RUNTIME_PROPERTY_SOURCE)) + .toList() + .forEach(sources::remove); + + // Add with the highest precedence so the freshly written values win over startup sources. + for (int i = loaded.size() - 1; i >= 0; i--) { + sources.addFirst(loaded.get(i)); + } + } catch (IOException e) { + throw new ConfigurationRefreshException("Failed to reload configuration from " + path, e); + } + } + + private void rebindConfigurationProperties() { + ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); + Binder binder = Binder.get(environment); + + Map beans = applicationContext.getBeansWithAnnotation(ConfigurationProperties.class); + beans.forEach((name, bean) -> rebindBean(beanFactory, binder, name, bean)); + } + + private void rebindBean(ConfigurableListableBeanFactory beanFactory, Binder binder, String name, Object bean) { + ConfigurationProperties annotation = + applicationContext.findAnnotationOnBean(name, ConfigurationProperties.class); + if (annotation == null) { + return; + } + String prefix = annotation.prefix().isEmpty() ? annotation.value() : annotation.prefix(); + + try { + if (bean.getClass().isRecord()) { + // Records (and other immutable, constructor-bound beans) cannot be mutated in place; + // re-create the instance and swap it into the singleton registry. + Object rebound = binder.bind(prefix, Bindable.of(bean.getClass())).orElse(null); + if (rebound != null && !rebound.equals(bean)) { + if (beanFactory instanceof DefaultSingletonBeanRegistry singletonRegistry) { + singletonRegistry.destroySingleton(name); + } + beanFactory.registerSingleton(name, rebound); + log.debug("Rebound (replaced) immutable @ConfigurationProperties bean '{}'", name); + } + } else { + binder.bind(prefix, Bindable.ofInstance(bean)); + log.debug("Rebound @ConfigurationProperties bean '{}' in place", name); + } + } catch (RuntimeException e) { + log.warn("Failed to rebind @ConfigurationProperties bean '{}' (prefix '{}')", name, prefix, e); + } + } + + static class ConfigurationRefreshException extends RuntimeException { + ConfigurationRefreshException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java b/base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java new file mode 100644 index 00000000..402f5222 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java @@ -0,0 +1,43 @@ +package ai.javaclaw.configuration; + +import org.springframework.context.ApplicationEvent; + +import java.util.Set; + +/** + * Published after the running {@link org.springframework.core.env.Environment} has been reloaded + * and all {@code @ConfigurationProperties} beans have been rebound from the freshly written + * configuration. + *

+ * Beans that perform runtime work based on configuration (channels opening a connection, the agent + * building its chat client, ...) should listen for this event and re-initialise their internal state + * so that configuration changes take effect without restarting the whole application. + *

+ * + * @see ConfigurationRebinder + */ +public class ConfigurationRefreshedEvent extends ApplicationEvent { + + private final Set changedKeys; + + public ConfigurationRefreshedEvent(Object source, Set changedKeys) { + super(source); + this.changedKeys = changedKeys == null ? Set.of() : Set.copyOf(changedKeys); + } + + /** + * The set of fully-qualified property keys (dotted notation) that changed in this refresh, + * e.g. {@code "agent.channels.telegram.token"}. + */ + public Set changedKeys() { + return changedKeys; + } + + /** + * Whether any changed key starts with the given prefix. Allows a listener to react only to the + * configuration it cares about, e.g. {@code hasChangeUnder("agent.channels.telegram")}. + */ + public boolean hasChangeUnder(String prefix) { + return changedKeys.stream().anyMatch(key -> key.equals(prefix) || key.startsWith(prefix + ".")); + } +} diff --git a/base/src/main/java/ai/javaclaw/llm/ChatClientRegistry.java b/base/src/main/java/ai/javaclaw/llm/ChatClientRegistry.java new file mode 100644 index 00000000..780c5dff --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/ChatClientRegistry.java @@ -0,0 +1,47 @@ +package ai.javaclaw.llm; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; + +import java.util.Set; + +/** + * Builds and caches a named {@link ChatClient} for each configured provider + * (see {@link LlmProviderProperties}). Rebuilt at runtime when the provider configuration changes, + * so adding/editing providers takes effect without restarting the application. + */ +public interface ChatClientRegistry { + + /** + * Returns the client for the given name, falling back to {@value LlmProviderProperties#DEFAULT_PROVIDER_NAME} + * if the name is not configured. + * + * @throws IllegalStateException if neither the requested name nor a default provider is configured + */ + ChatClient get(String name); + + /** + * Like {@link #get(String)} but never throws — if nothing is configured, returns a placeholder + * client that responds with a "no model configured" message. + */ + ChatClient getOrDefault(String name); + + /** + * Returns a fresh {@link ChatClient.Builder} for the given name (falling back to the default + * provider), so callers can layer on their own tools/advisors. Used to wire the main agent + * client and per-provider subagent routing. + */ + ChatClient.Builder builderFor(String name); + + /** + * Returns the underlying {@link ChatModel} for the given name (falling back to the default + * provider), or {@code null} if nothing is configured. Used to wrap models for fallback routing. + */ + ChatModel modelFor(String name); + + /** The names of all currently-configured providers. */ + Set availableNames(); + + /** Whether a provider with the given name is currently configured (and built successfully). */ + boolean has(String name); +} diff --git a/base/src/main/java/ai/javaclaw/llm/ChatModelFactory.java b/base/src/main/java/ai/javaclaw/llm/ChatModelFactory.java new file mode 100644 index 00000000..214dd8ed --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/ChatModelFactory.java @@ -0,0 +1,28 @@ +package ai.javaclaw.llm; + +import org.springframework.ai.chat.model.ChatModel; + +/** + * SPI implemented by each provider module to build a {@link ChatModel} from a + * {@link LlmProviderProperties.ProviderConfig} without relying on Spring AI auto-configuration. + *

+ * Keeping model construction in the provider modules (where the corresponding + * {@code spring-ai-starter-model-*} dependency lives) lets {@link ChatClientRegistry} build any + * configured provider while {@code base} stays free of provider-specific dependencies. + *

+ * Implementations are auto-discovered Spring beans. + */ +public interface ChatModelFactory { + + /** + * @return whether this factory can build a model for the given provider type + * (e.g. {@code "openai"}, {@code "anthropic"}, {@code "ollama"}, {@code "google.genai"}). + */ + boolean supports(String providerType); + + /** + * Builds a (lazily-connecting) {@link ChatModel}. Construction must not perform any network + * I/O — connectivity problems surface only when the model is actually called. + */ + ChatModel create(LlmProviderProperties.ProviderConfig config); +} diff --git a/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java b/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java new file mode 100644 index 00000000..2ac66ea5 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java @@ -0,0 +1,147 @@ +package ai.javaclaw.llm; + +import ai.javaclaw.configuration.ConfigurationRefreshedEvent; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Default {@link ChatClientRegistry}. Builds one {@link ChatModel} per configured provider via the + * {@link ChatModelFactory} SPI and rebuilds them when the provider configuration is hot-reloaded. + */ +@Component +public class DefaultChatClientRegistry implements ChatClientRegistry { + + private static final Logger log = LoggerFactory.getLogger(DefaultChatClientRegistry.class); + + private static final String NO_MODEL_MESSAGE = + "No AI model has been configured. Configure a provider under Settings → Model Providers " + + "(or finish onboarding) to start chatting."; + + private final List factories; + private final LlmProviderProperties properties; + + /** name -> built model. Successfully-built providers only. */ + private final Map models = new ConcurrentHashMap<>(); + /** Preserves configuration order for {@link #availableNames()}. */ + private final List order = new CopyOnWriteArrayList<>(); + + public DefaultChatClientRegistry(List factories, + LlmProviderProperties properties) { + this.factories = factories; + this.properties = properties; + rebuild(); + } + + @EventListener + public void onConfigurationRefreshed(ConfigurationRefreshedEvent event) { + if (event.changedKeys().isEmpty() || event.hasChangeUnder("agent.llm.providers")) { + log.info("Rebuilding chat clients after configuration refresh"); + rebuild(); + } + } + + public final synchronized void rebuild() { + Map configured = properties.getProviders(); + models.clear(); + order.clear(); + configured.forEach((name, config) -> { + try { + ChatModel model = build(name, config); + if (model != null) { + models.put(name, model); + order.add(name); + } + } catch (RuntimeException e) { + log.warn("Failed to build chat model for provider '{}' (type '{}'): {}", + name, config == null ? null : config.getProvider(), e.getMessage()); + } + }); + log.info("Chat client registry built with providers: {}", order); + } + + private ChatModel build(String name, ProviderConfig config) { + if (config == null || config.getProvider() == null || config.getProvider().isBlank()) { + log.warn("Provider '{}' has no 'provider' type configured; skipping", name); + return null; + } + String type = config.getProvider().trim().toLowerCase(); + ChatModelFactory factory = factories.stream() + .filter(f -> f.supports(type)) + .findFirst() + .orElse(null); + if (factory == null) { + log.warn("No ChatModelFactory available for provider type '{}' (provider '{}'); skipping", + type, name); + return null; + } + return factory.create(config); + } + + @Override + public ChatClient get(String name) { + ChatModel model = resolveModel(name); + if (model == null) { + throw new IllegalStateException("No chat model configured for provider '" + name + + "' and no default provider is available"); + } + return ChatClient.builder(model).build(); + } + + @Override + public ChatClient getOrDefault(String name) { + ChatModel model = resolveModel(name); + return ChatClient.builder(model != null ? model : placeholderModel()).build(); + } + + @Override + public ChatClient.Builder builderFor(String name) { + ChatModel model = resolveModel(name); + return ChatClient.builder(model != null ? model : placeholderModel()); + } + + @Override + public ChatModel modelFor(String name) { + return resolveModel(name); + } + + @Override + public Set availableNames() { + return Set.copyOf(order); + } + + @Override + public boolean has(String name) { + return models.containsKey(name); + } + + /** Resolves the model for a name, falling back to the default provider, or {@code null}. */ + private ChatModel resolveModel(String name) { + ChatModel model = models.get(name); + if (model != null) { + return model; + } + if (name != null && !LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name) && !models.isEmpty()) { + log.warn("Provider '{}' is not configured; falling back to '{}'", + name, LlmProviderProperties.DEFAULT_PROVIDER_NAME); + } + return models.get(LlmProviderProperties.DEFAULT_PROVIDER_NAME); + } + + private ChatModel placeholderModel() { + return prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage(NO_MODEL_MESSAGE)))); + } +} diff --git a/base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java b/base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java new file mode 100644 index 00000000..64027e5f --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java @@ -0,0 +1,44 @@ +package ai.javaclaw.llm; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.function.Supplier; + +/** + * A {@link ChatClient} that forwards every call to a delegate resolved on each invocation. This lets + * the main agent keep a single injected {@code ChatClient} reference while the underlying client is + * rebuilt (e.g. when the default provider's configuration changes) without restarting the context. + */ +public class DelegatingChatClient implements ChatClient { + + private final Supplier delegate; + + public DelegatingChatClient(Supplier delegate) { + this.delegate = delegate; + } + + private ChatClient delegate() { + return delegate.get(); + } + + @Override + public ChatClientRequestSpec prompt() { + return delegate().prompt(); + } + + @Override + public ChatClientRequestSpec prompt(String content) { + return delegate().prompt(content); + } + + @Override + public ChatClientRequestSpec prompt(Prompt prompt) { + return delegate().prompt(prompt); + } + + @Override + public Builder mutate() { + return delegate().mutate(); + } +} diff --git a/base/src/main/java/ai/javaclaw/llm/LlmConfiguration.java b/base/src/main/java/ai/javaclaw/llm/LlmConfiguration.java new file mode 100644 index 00000000..ba66db9a --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/LlmConfiguration.java @@ -0,0 +1,9 @@ +package ai.javaclaw.llm; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(LlmProviderProperties.class) +public class LlmConfiguration { +} diff --git a/base/src/main/java/ai/javaclaw/llm/LlmProviderProperties.java b/base/src/main/java/ai/javaclaw/llm/LlmProviderProperties.java new file mode 100644 index 00000000..6bd36920 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/LlmProviderProperties.java @@ -0,0 +1,91 @@ +package ai.javaclaw.llm; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Named map of LLM providers, bound from {@code agent.llm.providers.*}. + *

+ * Example configuration: + *

+ * agent:
+ *   llm:
+ *     providers:
+ *       default:
+ *         provider: openai
+ *         api-key: sk-...
+ *         model: gpt-4o
+ *       local:
+ *         provider: ollama
+ *         base-url: http://localhost:11434
+ *         model: llama3.2
+ * 
+ */ +@ConfigurationProperties(prefix = "agent.llm") +public class LlmProviderProperties { + + /** The reserved name of the primary provider used by the main agent conversation. */ + public static final String DEFAULT_PROVIDER_NAME = "default"; + + private Map providers = new LinkedHashMap<>(); + + public Map getProviders() { + return providers; + } + + public void setProviders(Map providers) { + this.providers = providers == null ? new LinkedHashMap<>() : providers; + } + + public static class ProviderConfig { + /** One of {@code openai}, {@code anthropic}, {@code ollama}, {@code google.genai}. */ + private String provider; + private String apiKey; + private String baseUrl; + private String model; + + public ProviderConfig() { + } + + public ProviderConfig(String provider, String apiKey, String baseUrl, String model) { + this.provider = provider; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.model = model; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + } +} diff --git a/base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java b/base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java new file mode 100644 index 00000000..bf4f057a --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java @@ -0,0 +1,107 @@ +package ai.javaclaw.llm; + +import ai.javaclaw.files.YamlDocument; +import ai.javaclaw.files.YamlParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +/** + * Reads the subagent definition files under {@code workspace/agents/} and exposes which provider + * name each one references via its {@code model:} frontmatter field. + */ +@Component +public class SubagentReferenceScanner { + + private static final Logger log = LoggerFactory.getLogger(SubagentReferenceScanner.class); + public static final String AGENTS_SUBDIRECTORY = "agents"; + + private final Resource workspace; + + public SubagentReferenceScanner(@Value("${agent.workspace:file:./workspace/}") Resource workspace) { + this.workspace = workspace; + } + + /** + * @param name the subagent name (its frontmatter {@code name}, or the file name without suffix) + * @param model the referenced provider name, or {@code null} if the subagent pins no model + */ + public record SubagentReference(String name, String model) { + } + + /** All subagent definitions found under {@code workspace/agents/}. Never throws. */ + public List scan() { + Path agentsDir = agentsDirectory(); + if (agentsDir == null || !Files.isDirectory(agentsDir)) { + return List.of(); + } + List references = new ArrayList<>(); + try (Stream files = Files.list(agentsDir)) { + files.filter(p -> p.getFileName().toString().endsWith(".md")) + .sorted() + .forEach(p -> toReference(p).ifPresent(references::add)); + } catch (IOException e) { + log.warn("Failed to list subagent directory {}: {}", agentsDir, e.getMessage()); + } + return references; + } + + /** The set of subagent names that reference the given provider name. */ + public Set namesReferencing(String providerName) { + Set names = new LinkedHashSet<>(); + for (SubagentReference reference : scan()) { + if (providerName != null && providerName.equals(reference.model())) { + names.add(reference.name()); + } + } + return names; + } + + private Optional toReference(Path file) { + try { + String content = Files.readString(file, StandardCharsets.UTF_8); + YamlDocument doc = YamlParser.parse(content); + String fileName = file.getFileName().toString(); + String defaultName = fileName.substring(0, fileName.length() - ".md".length()); + String name = doc.frontmatter().getOrDefault("name", defaultName); + // The "model" frontmatter is "" or ":"; the referenced + // provider is the part before the optional ':'. + String model = doc.frontmatter().get("model"); + if (model != null) { + int colon = model.indexOf(':'); + if (colon >= 0) { + model = model.substring(0, colon); + } + if (model.isBlank()) { + model = null; + } + } + return Optional.of(new SubagentReference(name, model)); + } catch (IOException e) { + log.warn("Failed to read subagent file {}: {}", file, e.getMessage()); + return Optional.empty(); + } + } + + /** The {@code workspace/agents} directory path, or {@code null} if it cannot be resolved. */ + public Path agentsDirectory() { + try { + return workspace.getFile().toPath().resolve(AGENTS_SUBDIRECTORY); + } catch (IOException e) { + throw new UncheckedIOException("Unable to resolve workspace directory", e); + } catch (RuntimeException e) { + log.debug("Workspace resource is not file-based; subagent scanning disabled"); + return null; + } + } +} diff --git a/base/src/main/java/ai/javaclaw/llm/SubagentStore.java b/base/src/main/java/ai/javaclaw/llm/SubagentStore.java new file mode 100644 index 00000000..34b18ff2 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/SubagentStore.java @@ -0,0 +1,117 @@ +package ai.javaclaw.llm; + +import ai.javaclaw.files.YamlDocument; +import ai.javaclaw.files.YamlParser; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Reads and writes subagent definition files under {@code workspace/agents/}. Each subagent is one + * Markdown file with {@code name}, {@code description} and {@code model} frontmatter and a free-form + * body (the subagent's instructions). + */ +@Component +public class SubagentStore { + + private final SubagentReferenceScanner scanner; + + public SubagentStore(SubagentReferenceScanner scanner) { + this.scanner = scanner; + } + + /** + * @param name the subagent id (also its file name without {@code .md}) + * @param model the provider name this subagent runs on + * @param description short summary of what the subagent does + * @param content the subagent's instructions (Markdown body) + */ + public record Subagent(String name, String model, String description, String content) { + } + + public List list() { + Path dir = scanner.agentsDirectory(); + if (dir == null || !Files.isDirectory(dir)) { + return List.of(); + } + List result = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + files.filter(p -> p.getFileName().toString().endsWith(".md")) + .sorted() + .forEach(p -> read(p).ifPresent(result::add)); + } catch (IOException e) { + throw new RuntimeException("Failed to list subagents", e); + } + return result; + } + + public Optional get(String name) { + Path file = fileFor(name); + if (file == null || !Files.isRegularFile(file)) { + return Optional.empty(); + } + return read(file); + } + + public boolean exists(String name) { + Path file = fileFor(name); + return file != null && Files.isRegularFile(file); + } + + public void save(Subagent subagent) throws IOException { + Path dir = scanner.agentsDirectory(); + if (dir == null) { + throw new IOException("Subagents directory is not available"); + } + Files.createDirectories(dir); + + Map frontmatter = new LinkedHashMap<>(); + frontmatter.put("name", subagent.name()); + if (subagent.description() != null && !subagent.description().isBlank()) { + frontmatter.put("description", subagent.description().strip()); + } + if (subagent.model() != null && !subagent.model().isBlank()) { + frontmatter.put("model", subagent.model().strip()); + } + String body = subagent.content() == null ? "" : subagent.content(); + String serialized = YamlParser.serialize(new YamlDocument(frontmatter, body)); + Files.writeString(dir.resolve(subagent.name() + ".md"), serialized, StandardCharsets.UTF_8); + } + + public boolean delete(String name) throws IOException { + Path file = fileFor(name); + if (file == null) { + return false; + } + return Files.deleteIfExists(file); + } + + private Optional read(Path file) { + try { + String raw = Files.readString(file, StandardCharsets.UTF_8); + YamlDocument doc = YamlParser.parse(raw); + String fileName = file.getFileName().toString(); + String defaultName = fileName.substring(0, fileName.length() - ".md".length()); + String name = doc.frontmatter().getOrDefault("name", defaultName); + String model = doc.frontmatter().getOrDefault("model", ""); + String description = doc.frontmatter().getOrDefault("description", ""); + return Optional.of(new Subagent(name, model, description, doc.body())); + } catch (IOException e) { + return Optional.empty(); + } + } + + private Path fileFor(String name) { + Path dir = scanner.agentsDirectory(); + return dir == null ? null : dir.resolve(name + ".md"); + } +} diff --git a/base/src/main/java/ai/javaclaw/tools/TaskTool.java b/base/src/main/java/ai/javaclaw/tools/JavaClawTaskTool.java similarity index 93% rename from base/src/main/java/ai/javaclaw/tools/TaskTool.java rename to base/src/main/java/ai/javaclaw/tools/JavaClawTaskTool.java index bd8bfb3a..3de10ed0 100644 --- a/base/src/main/java/ai/javaclaw/tools/TaskTool.java +++ b/base/src/main/java/ai/javaclaw/tools/JavaClawTaskTool.java @@ -15,14 +15,14 @@ * Creates and manages high-level tasks for AI assistants. * Each task is persisted as a Markdown file in the workspace directory. */ -public class TaskTool { +public class JavaClawTaskTool { - private static final Logger logger = LoggerFactory.getLogger(TaskTool.class); + private static final Logger logger = LoggerFactory.getLogger(JavaClawTaskTool.class); private final TaskManager taskManager; private final TaskEventHandler taskEventHandler; - public TaskTool(TaskManager taskManager, TaskEventHandler taskEventHandler) { + public JavaClawTaskTool(TaskManager taskManager, TaskEventHandler taskEventHandler) { this.taskManager = taskManager; this.taskEventHandler = taskEventHandler; } @@ -143,18 +143,18 @@ public static class Builder { private TaskEventHandler taskEventHandler; - public TaskTool.Builder taskManager(TaskManager taskManager) { + public JavaClawTaskTool.Builder taskManager(TaskManager taskManager) { this.taskManager = taskManager; return this; } - public TaskTool.Builder agentTaskEventHandler(TaskEventHandler taskEventHandler) { + public JavaClawTaskTool.Builder agentTaskEventHandler(TaskEventHandler taskEventHandler) { this.taskEventHandler = taskEventHandler; return this; } - public TaskTool build() { - return new TaskTool(this.taskManager, this.taskEventHandler); + public JavaClawTaskTool build() { + return new JavaClawTaskTool(this.taskManager, this.taskEventHandler); } } diff --git a/base/src/test/java/ai/javaclaw/llm/ChatClientRegistryTest.java b/base/src/test/java/ai/javaclaw/llm/ChatClientRegistryTest.java new file mode 100644 index 00000000..f32eff27 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/llm/ChatClientRegistryTest.java @@ -0,0 +1,80 @@ +package ai.javaclaw.llm; + +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ChatModel; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class ChatClientRegistryTest { + + /** Factory that returns a distinct model instance per provider name (keyed by model id). */ + private static ChatModelFactory factoryReturning(Map modelsByModelId) { + return new ChatModelFactory() { + @Override + public boolean supports(String providerType) { + return "test".equals(providerType); + } + + @Override + public ChatModel create(ProviderConfig config) { + return modelsByModelId.get(config.getModel()); + } + }; + } + + private static LlmProviderProperties propertiesWith(Map providers) { + LlmProviderProperties properties = new LlmProviderProperties(); + properties.setProviders(providers); + return properties; + } + + @Test + void missingNameFallsBackToDefault() { + ChatModel defaultModel = mock(ChatModel.class); + LlmProviderProperties properties = propertiesWith(Map.of( + "default", new ProviderConfig("test", null, null, "m-default"))); + DefaultChatClientRegistry registry = new DefaultChatClientRegistry( + List.of(factoryReturning(Map.of("m-default", defaultModel))), properties); + + assertThat(registry.availableNames()).containsExactly("default"); + assertThat(registry.has("default")).isTrue(); + assertThat(registry.has("missing")).isFalse(); + // An unknown name resolves to the default provider's model. + assertThat(registry.modelFor("missing")).isSameAs(defaultModel); + assertThat(registry.get("missing")).isNotNull(); + } + + @Test + void namedProviderResolvesToItsOwnModel() { + ChatModel defaultModel = mock(ChatModel.class); + ChatModel localModel = mock(ChatModel.class); + LlmProviderProperties properties = propertiesWith(Map.of( + "default", new ProviderConfig("test", null, null, "m-default"), + "local", new ProviderConfig("test", null, null, "m-local"))); + DefaultChatClientRegistry registry = new DefaultChatClientRegistry( + List.of(factoryReturning(Map.of("m-default", defaultModel, "m-local", localModel))), + properties); + + assertThat(registry.availableNames()).containsExactlyInAnyOrder("default", "local"); + assertThat(registry.modelFor("local")).isSameAs(localModel); + assertThat(registry.modelFor("default")).isSameAs(defaultModel); + } + + @Test + void unknownProviderTypeIsSkippedWithoutFailing() { + LlmProviderProperties properties = propertiesWith(Map.of( + "default", new ProviderConfig("nonexistent-type", null, null, "m"))); + DefaultChatClientRegistry registry = new DefaultChatClientRegistry(List.of(), properties); + + assertThat(registry.availableNames()).isEmpty(); + // get(...) throws when nothing is configured, getOrDefault never does. + assertThatThrownBy(() -> registry.get("default")).isInstanceOf(IllegalStateException.class); + assertThat(registry.getOrDefault("default")).isNotNull(); + } +} diff --git a/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicChatModelFactory.java b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicChatModelFactory.java new file mode 100644 index 00000000..1e641b61 --- /dev/null +++ b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicChatModelFactory.java @@ -0,0 +1,62 @@ +package ai.javaclaw.providers.anthropic; + +import ai.javaclaw.llm.ChatModelFactory; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.AnthropicClientImpl; +import com.anthropic.core.ClientOptions; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.http.okhttp.SpringAiAnthropicHttpClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.stereotype.Component; + +import static ai.javaclaw.providers.anthropic.AnthropticClaudeCodeConfiguration.CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER; + +/** + * Builds {@link AnthropicChatModel}. When the API key is the Claude Code OAuth placeholder, the + * model is wired to the {@link AnthropicClaudeCodeBackend} (system-wide Claude Code token) instead of + * a plain API key; otherwise the Anthropic client is constructed lazily from the chat options. + */ +@Component +public class AnthropicChatModelFactory implements ChatModelFactory { + + @Override + public boolean supports(String providerType) { + return "anthropic".equals(providerType); + } + + @Override + public ChatModel create(ProviderConfig config) { + AnthropicChatOptions.Builder options = AnthropicChatOptions.builder(); + if (config.getModel() != null && !config.getModel().isBlank()) { + options.model(config.getModel()); + } + if (config.getApiKey() != null && !config.getApiKey().isBlank()) { + options.apiKey(config.getApiKey()); + } + if (config.getBaseUrl() != null && !config.getBaseUrl().isBlank()) { + options.baseUrl(config.getBaseUrl().trim()); + } + AnthropicChatOptions built = options.build(); + + if (CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER.equals(config.getApiKey())) { + AnthropicClient client = claudeCodeClient(); + return AnthropicChatModel.builder() + .anthropicClient(client) + .anthropicClientAsync(client.async()) + .options(built) + .build(); + } + + return AnthropicChatModel.builder().options(built).build(); + } + + private static AnthropicClient claudeCodeClient() { + var httpClient = SpringAiAnthropicHttpClient.builder() + .backend(new AnthropicClaudeCodeBackend()) + .build(); + ClientOptions clientOptions = ClientOptions.builder().httpClient(httpClient).build(); + return new AnthropicClientImpl(clientOptions); + } +} diff --git a/providers/google/src/main/java/ai/javaclaw/providers/google/genai/GoogleGenAiChatModelFactory.java b/providers/google/src/main/java/ai/javaclaw/providers/google/genai/GoogleGenAiChatModelFactory.java new file mode 100644 index 00000000..bbcbb829 --- /dev/null +++ b/providers/google/src/main/java/ai/javaclaw/providers/google/genai/GoogleGenAiChatModelFactory.java @@ -0,0 +1,40 @@ +package ai.javaclaw.providers.google.genai; + +import ai.javaclaw.llm.ChatModelFactory; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import com.google.genai.Client; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.google.genai.GoogleGenAiChatModel; +import org.springframework.ai.google.genai.GoogleGenAiChatOptions; +import org.springframework.stereotype.Component; + +/** + * Builds {@link GoogleGenAiChatModel} for the {@code google.genai} provider type. Unlike the other + * providers, the Google client must be constructed explicitly from the API key. + */ +@Component +public class GoogleGenAiChatModelFactory implements ChatModelFactory { + + @Override + public boolean supports(String providerType) { + return "google.genai".equals(providerType) + || "google-genai".equals(providerType) + || "google".equals(providerType); + } + + @Override + public ChatModel create(ProviderConfig config) { + Client.Builder clientBuilder = Client.builder(); + if (config.getApiKey() != null && !config.getApiKey().isBlank()) { + clientBuilder.apiKey(config.getApiKey()); + } + GoogleGenAiChatOptions.Builder options = GoogleGenAiChatOptions.builder(); + if (config.getModel() != null && !config.getModel().isBlank()) { + options.model(config.getModel()); + } + return GoogleGenAiChatModel.builder() + .genAiClient(clientBuilder.build()) + .options(options.build()) + .build(); + } +} diff --git a/providers/ollama/src/main/java/ai/javaclaw/providers/ollama/OllamaChatModelFactory.java b/providers/ollama/src/main/java/ai/javaclaw/providers/ollama/OllamaChatModelFactory.java new file mode 100644 index 00000000..f63a8b27 --- /dev/null +++ b/providers/ollama/src/main/java/ai/javaclaw/providers/ollama/OllamaChatModelFactory.java @@ -0,0 +1,40 @@ +package ai.javaclaw.providers.ollama; + +import ai.javaclaw.llm.ChatModelFactory; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaChatOptions; +import org.springframework.stereotype.Component; + +/** + * Builds {@link OllamaChatModel} for the local, no-API-key {@code ollama} provider type. + */ +@Component +public class OllamaChatModelFactory implements ChatModelFactory { + + static final String DEFAULT_BASE_URL = "http://localhost:11434"; + + @Override + public boolean supports(String providerType) { + return "ollama".equals(providerType); + } + + @Override + public ChatModel create(ProviderConfig config) { + String baseUrl = (config.getBaseUrl() != null && !config.getBaseUrl().isBlank()) + ? config.getBaseUrl().trim() + : DEFAULT_BASE_URL; + OllamaApi ollamaApi = OllamaApi.builder().baseUrl(baseUrl).build(); + + OllamaChatOptions.Builder options = OllamaChatOptions.builder(); + if (config.getModel() != null && !config.getModel().isBlank()) { + options.model(config.getModel()); + } + return OllamaChatModel.builder() + .ollamaApi(ollamaApi) + .options(options.build()) + .build(); + } +} diff --git a/providers/openai/src/main/java/ai/javaclaw/providers/openai/OpenAiChatModelFactory.java b/providers/openai/src/main/java/ai/javaclaw/providers/openai/OpenAiChatModelFactory.java new file mode 100644 index 00000000..b6841fc7 --- /dev/null +++ b/providers/openai/src/main/java/ai/javaclaw/providers/openai/OpenAiChatModelFactory.java @@ -0,0 +1,36 @@ +package ai.javaclaw.providers.openai; + +import ai.javaclaw.llm.ChatModelFactory; +import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.stereotype.Component; + +/** + * Builds {@link OpenAiChatModel} for the {@code openai} provider type. The OpenAI client is + * constructed lazily by the model from the API key / base URL carried on {@link OpenAiChatOptions}. + */ +@Component +public class OpenAiChatModelFactory implements ChatModelFactory { + + @Override + public boolean supports(String providerType) { + return "openai".equals(providerType); + } + + @Override + public ChatModel create(ProviderConfig config) { + OpenAiChatOptions.Builder options = OpenAiChatOptions.builder(); + if (config.getModel() != null && !config.getModel().isBlank()) { + options.model(config.getModel()); + } + if (config.getApiKey() != null && !config.getApiKey().isBlank()) { + options.apiKey(config.getApiKey()); + } + if (config.getBaseUrl() != null && !config.getBaseUrl().isBlank()) { + options.baseUrl(config.getBaseUrl().trim()); + } + return OpenAiChatModel.builder().options(options.build()).build(); + } +} From 2adcb245f8dffd6b01449f1d47dde8b36dbc08b1 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Fri, 3 Jul 2026 00:30:18 +0300 Subject: [PATCH 02/11] Simplify agent/provider internals - Consolidate all workspace/agents/ access into SubagentStore and remove the duplicate SubagentReferenceScanner (single frontmatter parser). - Route subagents by provider name only in MainChatClientProvider; drop the redundant provider-type builder keying and its LlmProviderProperties dependency. - Trim ProviderController to a read-only GET (used for the agents-page model list); remove the unused ProviderForm. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../providers/api/ProviderController.java | 140 +----------------- .../javaclaw/providers/api/ProviderForm.java | 19 --- .../providers/api/ProviderControllerTest.java | 82 ++-------- .../ai/javaclaw/MainChatClientProvider.java | 18 +-- .../llm/SubagentReferenceScanner.java | 107 ------------- .../java/ai/javaclaw/llm/SubagentStore.java | 48 ++++-- 6 files changed, 57 insertions(+), 357 deletions(-) delete mode 100644 app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java delete mode 100644 base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java diff --git a/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java b/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java index 11ae00f1..610753db 100644 --- a/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java +++ b/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java @@ -1,151 +1,37 @@ package ai.javaclaw.providers.api; -import ai.javaclaw.configuration.ConfigurationManager; -import ai.javaclaw.llm.ChatModelFactory; import ai.javaclaw.llm.LlmProviderProperties; import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; -import ai.javaclaw.llm.SubagentReferenceScanner; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.io.IOException; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.regex.Pattern; /** - * REST API for managing the named LLM providers under {@code agent.llm.providers}. Saving fires a - * {@code ConfigurationChangedEvent}, which the {@code ConfigurationRebinder} turns into a - * {@code ConfigurationRefreshedEvent} so the {@code ChatClientRegistry} rebuilds affected clients. + * Read-only REST API for the configured LLM providers under {@code agent.llm.providers}. Providers + * are created/edited as part of agents (see {@code SubagentController}) or in configuration; this + * endpoint exposes them (e.g. to populate the model list on the agents page). The raw API key is + * never returned — only a masked form. */ @RestController @RequestMapping("/api/providers") public class ProviderController { - private static final Pattern NAME_PATTERN = Pattern.compile("[a-z0-9-]+"); - private static final String CONFIG_PREFIX = "agent.llm.providers"; - private final LlmProviderProperties properties; - private final ConfigurationManager configurationManager; - private final SubagentReferenceScanner subagentScanner; - private final List chatModelFactories; - public ProviderController(LlmProviderProperties properties, - ConfigurationManager configurationManager, - SubagentReferenceScanner subagentScanner, - List chatModelFactories) { + public ProviderController(LlmProviderProperties properties) { this.properties = properties; - this.configurationManager = configurationManager; - this.subagentScanner = subagentScanner; - this.chatModelFactories = chatModelFactories; - } - - private Map currentProviders() { - return properties.getProviders(); } @GetMapping public List list() { List result = new ArrayList<>(); - currentProviders().forEach((name, config) -> result.add(toDto(name, config))); + properties.getProviders().forEach((name, config) -> result.add(toDto(name, config))); return result; } - @PostMapping - public ResponseEntity create(@RequestBody ProviderForm form) { - String name = form.name() == null ? "" : form.name().trim(); - if (name.isBlank() || !NAME_PATTERN.matcher(name).matches()) { - return badRequest("Provider name must match [a-z0-9-]+"); - } - if (LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name)) { - return badRequest("The 'default' provider is created during onboarding and can only be edited"); - } - if (currentProviders().containsKey(name)) { - return badRequest("A provider named '" + name + "' already exists"); - } - if (!isKnownProviderType(form.provider())) { - return badRequest("Unknown provider type: " + form.provider()); - } - - save(name, form); - return ResponseEntity.status(HttpStatus.CREATED).body(toDto(name, form.toConfig())); - } - - @PutMapping("/{name}") - public ResponseEntity update(@PathVariable String name, @RequestBody ProviderForm form) { - ProviderConfig existing = currentProviders().get(name); - if (existing == null) { - return ResponseEntity.notFound().build(); - } - - // Name and provider type are immutable on edit. - ProviderForm effective = new ProviderForm(name, existing.getProvider(), form.model(), form.apiKey(), form.baseUrl()); - save(name, effective); - return ResponseEntity.ok(toDto(name, effective.toConfig())); - } - - @DeleteMapping("/{name}") - public ResponseEntity delete(@PathVariable String name, - @RequestParam(defaultValue = "false") boolean force) { - if (!currentProviders().containsKey(name)) { - return ResponseEntity.notFound().build(); - } - if (LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name)) { - return badRequest("The default provider cannot be removed"); - } - - Set referencedBy = subagentScanner.namesReferencing(name); - if (!referencedBy.isEmpty() && !force) { - Map body = new LinkedHashMap<>(); - body.put("message", "'" + name + "' is referenced by " + referencedBy.size() + " subagent(s)"); - body.put("affected", new ArrayList<>(referencedBy)); - return ResponseEntity.status(HttpStatus.CONFLICT).body(body); - } - - try { - configurationManager.removeProperty(CONFIG_PREFIX + "." + name); - } catch (IOException e) { - return ResponseEntity.internalServerError().body(error("Failed to update configuration: " + e.getMessage())); - } - return ResponseEntity.noContent().build(); - } - - // --- helpers --- - - private void save(String name, ProviderForm form) { - String base = CONFIG_PREFIX + "." + name; - Map props = new LinkedHashMap<>(); - props.put(base + ".provider", form.provider()); - if (notBlank(form.model())) props.put(base + ".model", form.model()); - if (notBlank(form.apiKey())) props.put(base + ".api-key", form.apiKey()); - if (notBlank(form.baseUrl())) props.put(base + ".base-url", form.baseUrl()); - try { - configurationManager.updateProperties(props); - } catch (IOException e) { - throw new IllegalStateException("Failed to write provider configuration", e); - } - } - - private boolean isKnownProviderType(String type) { - if (type == null || type.isBlank()) { - return false; - } - String normalized = type.trim().toLowerCase(); - return chatModelFactories.stream().anyMatch(f -> f.supports(normalized)); - } - private ProviderDto toDto(String name, ProviderConfig config) { boolean isDefault = LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name); return new ProviderDto(name, config.getProvider(), config.getModel(), @@ -162,18 +48,4 @@ private static String maskApiKey(String apiKey) { } return key.substring(0, 3) + "..." + key.substring(key.length() - 4); } - - private static boolean notBlank(String value) { - return value != null && !value.isBlank(); - } - - private static ResponseEntity> badRequest(String message) { - return ResponseEntity.badRequest().body(error(message)); - } - - private static Map error(String message) { - Map body = new LinkedHashMap<>(); - body.put("message", message); - return body; - } } diff --git a/app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java b/app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java deleted file mode 100644 index af093059..00000000 --- a/app/src/main/java/ai/javaclaw/providers/api/ProviderForm.java +++ /dev/null @@ -1,19 +0,0 @@ -package ai.javaclaw.providers.api; - -import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; - -/** - * Write model for creating/updating a provider and for the standalone connection test. Carries the - * raw API key (request bodies only — never serialized back to the client). - */ -public record ProviderForm( - String name, - String provider, - String model, - String apiKey, - String baseUrl) { - - public ProviderConfig toConfig() { - return new ProviderConfig(provider, apiKey, baseUrl, model); - } -} diff --git a/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java b/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java index cb5a0e7a..e1a41692 100644 --- a/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java +++ b/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java @@ -1,27 +1,17 @@ package ai.javaclaw.providers.api; -import ai.javaclaw.configuration.ConfigurationManager; -import ai.javaclaw.llm.ChatModelFactory; import ai.javaclaw.llm.LlmProviderProperties; import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; -import ai.javaclaw.llm.SubagentReferenceScanner; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.util.Map; -import java.util.Set; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -34,65 +24,17 @@ class ProviderControllerTest { @MockitoBean private LlmProviderProperties properties; - @MockitoBean - private ConfigurationManager configurationManager; - - @MockitoBean - private SubagentReferenceScanner subagentScanner; - - @MockitoBean - private ChatModelFactory chatModelFactory; - - @BeforeEach - void setUp() { - when(chatModelFactory.supports("openai")).thenReturn(true); - } - - @Test - void createPersistsProvider() throws Exception { - when(properties.getProviders()).thenReturn(Map.of()); - - mockMvc.perform(post("/api/providers") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"local\",\"provider\":\"openai\",\"model\":\"gpt-4o\",\"apiKey\":\"sk-test\"}")) - .andExpect(status().isCreated()); - - verify(configurationManager).updateProperties(any()); - } - @Test - void createRejectsUnknownProviderType() throws Exception { - when(properties.getProviders()).thenReturn(Map.of()); - - mockMvc.perform(post("/api/providers") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"local\",\"provider\":\"ghost\",\"model\":\"x\",\"apiKey\":\"k\"}")) - .andExpect(status().isBadRequest()); - - verify(configurationManager, never()).updateProperties(any()); - } - - @Test - void deleteReturnsConflictWhenReferencedBySubagents() throws Exception { - when(properties.getProviders()).thenReturn(Map.of("local", new ProviderConfig("openai", "k", null, "gpt-4o"))); - when(subagentScanner.namesReferencing("local")).thenReturn(Set.of("summariser", "classifier")); - - mockMvc.perform(delete("/api/providers/local")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.affected").isArray()) - .andExpect(jsonPath("$.affected.length()").value(2)); - - verify(configurationManager, never()).removeProperty(any()); - } - - @Test - void deleteWithForceSucceedsEvenWhenReferenced() throws Exception { - when(properties.getProviders()).thenReturn(Map.of("local", new ProviderConfig("openai", "k", null, "gpt-4o"))); - when(subagentScanner.namesReferencing("local")).thenReturn(Set.of("summariser")); - - mockMvc.perform(delete("/api/providers/local").param("force", "true")) - .andExpect(status().isNoContent()); - - verify(configurationManager).removeProperty("agent.llm.providers.local"); + void listReturnsConfiguredProvidersWithMaskedKey() throws Exception { + when(properties.getProviders()).thenReturn(Map.of( + "default", new ProviderConfig("openai", "sk-supersecretkey", null, "gpt-4o"))); + + mockMvc.perform(get("/api/providers")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("default")) + .andExpect(jsonPath("$[0].provider").value("openai")) + .andExpect(jsonPath("$[0].model").value("gpt-4o")) + .andExpect(jsonPath("$[0].isDefault").value(true)) + .andExpect(jsonPath("$[0].apiKeyMasked").value("sk-...tkey")); } } diff --git a/base/src/main/java/ai/javaclaw/MainChatClientProvider.java b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java index 270c4d64..ce122669 100644 --- a/base/src/main/java/ai/javaclaw/MainChatClientProvider.java +++ b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java @@ -4,7 +4,7 @@ import ai.javaclaw.configuration.ConfigurationRefreshedEvent; import ai.javaclaw.llm.ChatClientRegistry; import ai.javaclaw.llm.LlmProviderProperties; -import ai.javaclaw.llm.SubagentReferenceScanner; +import ai.javaclaw.llm.SubagentStore; import ai.javaclaw.tasks.TaskManager; import ai.javaclaw.tools.AgentEnvironment; import ai.javaclaw.tools.AutoDiscoveredTool; @@ -62,8 +62,7 @@ public class MainChatClientProvider { private final SyncMcpToolCallbackProvider mcpToolProvider; private final TaskManager taskManager; private final ConfigurationManager configurationManager; - private final SubagentReferenceScanner subagentScanner; - private final LlmProviderProperties providerProperties; + private final SubagentStore subagentStore; private final Set> autoDiscoveredTools; private final Resource workspace; private final List skillPaths; @@ -76,8 +75,7 @@ public MainChatClientProvider(ChatClientRegistry registry, SyncMcpToolCallbackProvider mcpToolProvider, TaskManager taskManager, ConfigurationManager configurationManager, - SubagentReferenceScanner subagentScanner, - LlmProviderProperties providerProperties, + SubagentStore subagentStore, Set> autoDiscoveredTools, @Value("${agent.workspace:Unknown}") Resource workspace, @Value("${agent.skills.paths}") List skillPaths) { @@ -87,8 +85,7 @@ public MainChatClientProvider(ChatClientRegistry registry, this.mcpToolProvider = mcpToolProvider; this.taskManager = taskManager; this.configurationManager = configurationManager; - this.subagentScanner = subagentScanner; - this.providerProperties = providerProperties; + this.subagentStore = subagentStore; this.autoDiscoveredTools = autoDiscoveredTools; this.workspace = workspace; this.skillPaths = skillPaths; @@ -151,20 +148,17 @@ private ChatClient build() { * {@code workspace/agents/}. */ private ToolCallback buildSubagentTaskTool() { - Path agentsDir = subagentScanner.agentsDirectory(); + Path agentsDir = subagentStore.agentsDirectory(); if (agentsDir == null) { return null; } try { Files.createDirectories(agentsDir); + // Agents reference their own provider entry by name, so builders are keyed by provider name. Map builders = new LinkedHashMap<>(); for (String name : registry.availableNames()) { builders.put(name, registry.builderFor(name)); - LlmProviderProperties.ProviderConfig config = providerProperties.getProviders().get(name); - if (config != null && config.getProvider() != null && !config.getProvider().isBlank()) { - builders.putIfAbsent(config.getProvider().trim().toLowerCase(), registry.builderFor(name)); - } } if (builders.isEmpty()) { return null; diff --git a/base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java b/base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java deleted file mode 100644 index bf4f057a..00000000 --- a/base/src/main/java/ai/javaclaw/llm/SubagentReferenceScanner.java +++ /dev/null @@ -1,107 +0,0 @@ -package ai.javaclaw.llm; - -import ai.javaclaw.files.YamlDocument; -import ai.javaclaw.files.YamlParser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.stream.Stream; - -/** - * Reads the subagent definition files under {@code workspace/agents/} and exposes which provider - * name each one references via its {@code model:} frontmatter field. - */ -@Component -public class SubagentReferenceScanner { - - private static final Logger log = LoggerFactory.getLogger(SubagentReferenceScanner.class); - public static final String AGENTS_SUBDIRECTORY = "agents"; - - private final Resource workspace; - - public SubagentReferenceScanner(@Value("${agent.workspace:file:./workspace/}") Resource workspace) { - this.workspace = workspace; - } - - /** - * @param name the subagent name (its frontmatter {@code name}, or the file name without suffix) - * @param model the referenced provider name, or {@code null} if the subagent pins no model - */ - public record SubagentReference(String name, String model) { - } - - /** All subagent definitions found under {@code workspace/agents/}. Never throws. */ - public List scan() { - Path agentsDir = agentsDirectory(); - if (agentsDir == null || !Files.isDirectory(agentsDir)) { - return List.of(); - } - List references = new ArrayList<>(); - try (Stream files = Files.list(agentsDir)) { - files.filter(p -> p.getFileName().toString().endsWith(".md")) - .sorted() - .forEach(p -> toReference(p).ifPresent(references::add)); - } catch (IOException e) { - log.warn("Failed to list subagent directory {}: {}", agentsDir, e.getMessage()); - } - return references; - } - - /** The set of subagent names that reference the given provider name. */ - public Set namesReferencing(String providerName) { - Set names = new LinkedHashSet<>(); - for (SubagentReference reference : scan()) { - if (providerName != null && providerName.equals(reference.model())) { - names.add(reference.name()); - } - } - return names; - } - - private Optional toReference(Path file) { - try { - String content = Files.readString(file, StandardCharsets.UTF_8); - YamlDocument doc = YamlParser.parse(content); - String fileName = file.getFileName().toString(); - String defaultName = fileName.substring(0, fileName.length() - ".md".length()); - String name = doc.frontmatter().getOrDefault("name", defaultName); - // The "model" frontmatter is "" or ":"; the referenced - // provider is the part before the optional ':'. - String model = doc.frontmatter().get("model"); - if (model != null) { - int colon = model.indexOf(':'); - if (colon >= 0) { - model = model.substring(0, colon); - } - if (model.isBlank()) { - model = null; - } - } - return Optional.of(new SubagentReference(name, model)); - } catch (IOException e) { - log.warn("Failed to read subagent file {}: {}", file, e.getMessage()); - return Optional.empty(); - } - } - - /** The {@code workspace/agents} directory path, or {@code null} if it cannot be resolved. */ - public Path agentsDirectory() { - try { - return workspace.getFile().toPath().resolve(AGENTS_SUBDIRECTORY); - } catch (IOException e) { - throw new UncheckedIOException("Unable to resolve workspace directory", e); - } catch (RuntimeException e) { - log.debug("Workspace resource is not file-based; subagent scanning disabled"); - return null; - } - } -} diff --git a/base/src/main/java/ai/javaclaw/llm/SubagentStore.java b/base/src/main/java/ai/javaclaw/llm/SubagentStore.java index 34b18ff2..204d3fe3 100644 --- a/base/src/main/java/ai/javaclaw/llm/SubagentStore.java +++ b/base/src/main/java/ai/javaclaw/llm/SubagentStore.java @@ -2,6 +2,10 @@ import ai.javaclaw.files.YamlDocument; import ai.javaclaw.files.YamlParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; import java.io.IOException; @@ -16,30 +20,33 @@ import java.util.stream.Stream; /** - * Reads and writes subagent definition files under {@code workspace/agents/}. Each subagent is one - * Markdown file with {@code name}, {@code description} and {@code model} frontmatter and a free-form - * body (the subagent's instructions). + * Single point of access for the agent definition files under {@code workspace/agents/}. Each agent + * is one Markdown file with {@code name}, {@code description} and {@code model} frontmatter and a + * free-form body (the agent's instructions). */ @Component public class SubagentStore { - private final SubagentReferenceScanner scanner; + private static final Logger log = LoggerFactory.getLogger(SubagentStore.class); + private static final String AGENTS_SUBDIRECTORY = "agents"; - public SubagentStore(SubagentReferenceScanner scanner) { - this.scanner = scanner; + private final Resource workspace; + + public SubagentStore(@Value("${agent.workspace:file:./workspace/}") Resource workspace) { + this.workspace = workspace; } /** - * @param name the subagent id (also its file name without {@code .md}) - * @param model the provider name this subagent runs on - * @param description short summary of what the subagent does - * @param content the subagent's instructions (Markdown body) + * @param name the agent id (also its file name without {@code .md}) + * @param model the routing value ({@code ""} or {@code ":"}) + * @param description short summary of what the agent does + * @param content the agent's instructions (Markdown body) */ public record Subagent(String name, String model, String description, String content) { } public List list() { - Path dir = scanner.agentsDirectory(); + Path dir = agentsDirectory(); if (dir == null || !Files.isDirectory(dir)) { return List.of(); } @@ -49,7 +56,7 @@ public List list() { .sorted() .forEach(p -> read(p).ifPresent(result::add)); } catch (IOException e) { - throw new RuntimeException("Failed to list subagents", e); + throw new RuntimeException("Failed to list agents", e); } return result; } @@ -68,9 +75,9 @@ public boolean exists(String name) { } public void save(Subagent subagent) throws IOException { - Path dir = scanner.agentsDirectory(); + Path dir = agentsDirectory(); if (dir == null) { - throw new IOException("Subagents directory is not available"); + throw new IOException("Agents directory is not available"); } Files.createDirectories(dir); @@ -95,6 +102,16 @@ public boolean delete(String name) throws IOException { return Files.deleteIfExists(file); } + /** The {@code workspace/agents} directory path, or {@code null} if it cannot be resolved. */ + public Path agentsDirectory() { + try { + return workspace.getFile().toPath().resolve(AGENTS_SUBDIRECTORY); + } catch (IOException | RuntimeException e) { + log.debug("Workspace resource is not file-based; agent storage disabled"); + return null; + } + } + private Optional read(Path file) { try { String raw = Files.readString(file, StandardCharsets.UTF_8); @@ -106,12 +123,13 @@ private Optional read(Path file) { String description = doc.frontmatter().getOrDefault("description", ""); return Optional.of(new Subagent(name, model, description, doc.body())); } catch (IOException e) { + log.warn("Failed to read agent file {}: {}", file, e.getMessage()); return Optional.empty(); } } private Path fileFor(String name) { - Path dir = scanner.agentsDirectory(); + Path dir = agentsDirectory(); return dir == null ? null : dir.resolve(name + ".md"); } } From 7597c6663e455f4caee44091222018e77c3a4b73 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Fri, 3 Jul 2026 21:18:57 +0300 Subject: [PATCH 03/11] Remove read-only providers REST endpoint Drop the /api/providers controller, DTO, and test, and simplify the agents page to default the model field from the selected provider instead of populating it from the configured provider list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../providers/api/ProviderController.java | 51 ------------------- .../javaclaw/providers/api/ProviderDto.java | 21 -------- .../providers/api/SettingsController.java | 4 +- .../templates/settings/agents.html.peb | 21 ++------ .../providers/api/ProviderControllerTest.java | 40 --------------- 5 files changed, 5 insertions(+), 132 deletions(-) delete mode 100644 app/src/main/java/ai/javaclaw/providers/api/ProviderController.java delete mode 100644 app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java delete mode 100644 app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java diff --git a/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java b/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java deleted file mode 100644 index 610753db..00000000 --- a/app/src/main/java/ai/javaclaw/providers/api/ProviderController.java +++ /dev/null @@ -1,51 +0,0 @@ -package ai.javaclaw.providers.api; - -import ai.javaclaw.llm.LlmProviderProperties; -import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.ArrayList; -import java.util.List; - -/** - * Read-only REST API for the configured LLM providers under {@code agent.llm.providers}. Providers - * are created/edited as part of agents (see {@code SubagentController}) or in configuration; this - * endpoint exposes them (e.g. to populate the model list on the agents page). The raw API key is - * never returned — only a masked form. - */ -@RestController -@RequestMapping("/api/providers") -public class ProviderController { - - private final LlmProviderProperties properties; - - public ProviderController(LlmProviderProperties properties) { - this.properties = properties; - } - - @GetMapping - public List list() { - List result = new ArrayList<>(); - properties.getProviders().forEach((name, config) -> result.add(toDto(name, config))); - return result; - } - - private ProviderDto toDto(String name, ProviderConfig config) { - boolean isDefault = LlmProviderProperties.DEFAULT_PROVIDER_NAME.equals(name); - return new ProviderDto(name, config.getProvider(), config.getModel(), - config.getBaseUrl(), maskApiKey(config.getApiKey()), isDefault); - } - - private static String maskApiKey(String apiKey) { - if (apiKey == null || apiKey.isBlank()) { - return ""; - } - String key = apiKey.trim(); - if (key.length() <= 7) { - return "••••"; - } - return key.substring(0, 3) + "..." + key.substring(key.length() - 4); - } -} diff --git a/app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java b/app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java deleted file mode 100644 index 8126ec6b..00000000 --- a/app/src/main/java/ai/javaclaw/providers/api/ProviderDto.java +++ /dev/null @@ -1,21 +0,0 @@ -package ai.javaclaw.providers.api; - -/** - * Read model for a configured provider as returned by {@code GET /api/providers}. Never carries the - * raw API key — only a masked form for display. - * - * @param name provider name (the key under {@code agent.llm.providers}) - * @param provider provider type (openai | anthropic | ollama | google.genai | ...) - * @param model configured model id - * @param baseUrl base URL (relevant for ollama / self-hosted) - * @param apiKeyMasked masked API key for cloud providers, e.g. {@code sk-...a1b2}; empty otherwise - * @param isDefault whether this is the reserved {@code default} provider - */ -public record ProviderDto( - String name, - String provider, - String model, - String baseUrl, - String apiKeyMasked, - boolean isDefault) { -} diff --git a/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java b/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java index 5bb234f0..6ac6334c 100644 --- a/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java +++ b/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java @@ -4,8 +4,8 @@ import org.springframework.web.bind.annotation.GetMapping; /** - * Serves the post-onboarding settings pages. The Subagents page is a thin shell that talks to - * {@link SubagentController} (and reads provider names from {@link ProviderController}) via fetch. + * Serves the post-onboarding settings pages. The Agents page is a thin shell that talks to + * {@link SubagentController} via fetch. */ @Controller public class SettingsController { diff --git a/app/src/main/resources/templates/settings/agents.html.peb b/app/src/main/resources/templates/settings/agents.html.peb index 97656adc..276ae910 100644 --- a/app/src/main/resources/templates/settings/agents.html.peb +++ b/app/src/main/resources/templates/settings/agents.html.peb @@ -91,10 +91,9 @@
- - +
-

Models available in your configuration (you can also type one).

+

Defaults to the provider's model; type another to override.

@@ -146,11 +145,9 @@ {% endblock %} diff --git a/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java b/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java deleted file mode 100644 index e1a41692..00000000 --- a/app/src/test/java/ai/javaclaw/providers/api/ProviderControllerTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package ai.javaclaw.providers.api; - -import ai.javaclaw.llm.LlmProviderProperties; -import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Map; - -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(ProviderController.class) -class ProviderControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private LlmProviderProperties properties; - - @Test - void listReturnsConfiguredProvidersWithMaskedKey() throws Exception { - when(properties.getProviders()).thenReturn(Map.of( - "default", new ProviderConfig("openai", "sk-supersecretkey", null, "gpt-4o"))); - - mockMvc.perform(get("/api/providers")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("default")) - .andExpect(jsonPath("$[0].provider").value("openai")) - .andExpect(jsonPath("$[0].model").value("gpt-4o")) - .andExpect(jsonPath("$[0].isDefault").value(true)) - .andExpect(jsonPath("$[0].apiKeyMasked").value("sk-...tkey")); - } -} From dac7c664e5d704fcb940ce81dc28334231297973 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Fri, 3 Jul 2026 23:08:53 +0300 Subject: [PATCH 04/11] Always restart on configuration change Remove the provider-change-only early return so any configuration change triggers a full application restart. --- app/src/main/java/ai/javaclaw/JavaClawApplication.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/src/main/java/ai/javaclaw/JavaClawApplication.java b/app/src/main/java/ai/javaclaw/JavaClawApplication.java index 09e92021..27eb7d39 100644 --- a/app/src/main/java/ai/javaclaw/JavaClawApplication.java +++ b/app/src/main/java/ai/javaclaw/JavaClawApplication.java @@ -44,16 +44,6 @@ public void run(ApplicationArguments args) throws Exception { @EventListener public void on(ConfigurationChangedEvent configurationChangedEvent) { - // Model-provider changes are applied at runtime via ConfigurationRebinder + - // ChatClientRegistry (hot reload), so they must NOT trigger a full restart. - boolean providerChangeOnly = !configurationChangedEvent.changedKeys().isEmpty() - && configurationChangedEvent.changedKeys().stream() - .allMatch(key -> key.equals("agent.llm.providers") || key.startsWith("agent.llm.providers.")); - if (providerChangeOnly) { - log.info("Configuration change limited to model providers; applying without restart."); - return; - } - ApplicationArguments args = applicationContext.getBean(ApplicationArguments.class); Thread thread = new Thread(() -> { From d94592a86b45a08a4eeefd82de68720428f4275c Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 4 Jul 2026 10:14:50 +0300 Subject: [PATCH 05/11] Remove settings controller --- .../providers/api/SettingsController.java | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 app/src/main/java/ai/javaclaw/providers/api/SettingsController.java diff --git a/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java b/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java deleted file mode 100644 index 6ac6334c..00000000 --- a/app/src/main/java/ai/javaclaw/providers/api/SettingsController.java +++ /dev/null @@ -1,17 +0,0 @@ -package ai.javaclaw.providers.api; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; - -/** - * Serves the post-onboarding settings pages. The Agents page is a thin shell that talks to - * {@link SubagentController} via fetch. - */ -@Controller -public class SettingsController { - - @GetMapping("/settings/agents") - public String agents() { - return "settings/agents"; - } -} From 43698de6963d5ece88513cfbe51804f980558bcc Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 4 Jul 2026 10:17:14 +0300 Subject: [PATCH 06/11] uncomment systemPrompt --- base/src/main/java/ai/javaclaw/MainChatClientProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/java/ai/javaclaw/MainChatClientProvider.java b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java index ce122669..c31bf495 100644 --- a/base/src/main/java/ai/javaclaw/MainChatClientProvider.java +++ b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java @@ -116,7 +116,7 @@ private ChatClient build() { } builder.defaultAdvisors(new SimpleLoggerAdvisor()) - //.defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info())) + .defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info())) .defaultTools((Object[]) mcpToolProvider.getToolCallbacks()) .defaultTools(SkillsTool.builder() .addSkillsDirectory(skillsDir().toString()) From 39eb7193ea5f87d4ff1845f08877704393cd3497 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 4 Jul 2026 12:39:00 +0300 Subject: [PATCH 07/11] Drop runtime chat-client hot reload Configuration changes trigger a full application restart, so the delegating chat client and the configuration rebind/refresh machinery are no longer needed. Remove DelegatingChatClient, ConfigurationRebinder and ConfigurationRefreshedEvent, and build the main chat client and provider registry once per context. --- .../ai/javaclaw/JavaClawConfiguration.java | 10 +- .../ai/javaclaw/MainChatClientProvider.java | 23 +-- .../configuration/ConfigurationRebinder.java | 151 ------------------ .../ConfigurationRefreshedEvent.java | 43 ----- .../llm/DefaultChatClientRegistry.java | 13 +- .../ai/javaclaw/llm/DelegatingChatClient.java | 44 ----- 6 files changed, 12 insertions(+), 272 deletions(-) delete mode 100644 base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java delete mode 100644 base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java delete mode 100644 base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java diff --git a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java index 33910c87..fb2b57d3 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -1,6 +1,5 @@ package ai.javaclaw; -import ai.javaclaw.llm.DelegatingChatClient; import ai.javaclaw.tasks.TaskManager; import ai.javaclaw.tools.JavaClawTaskTool; import org.springframework.ai.chat.client.ChatClient; @@ -52,13 +51,12 @@ public ChatClient.Builder chatClientBuilder(ObjectProvider chatModelP } /** - * The main agent {@link ChatClient}. Delegates to {@link MainChatClientProvider}, which builds it - * from the {@code default} provider in {@code agent.llm.providers} and rebuilds it on a - * configuration refresh — so the single injected reference always reflects the current default - * provider without restarting the application. + * The main agent {@link ChatClient}, built from the {@code default} provider in + * {@code agent.llm.providers}. Configuration changes trigger a full application restart, so this + * is built once per context. */ @Bean public ChatClient chatClient(MainChatClientProvider mainChatClientProvider) { - return new DelegatingChatClient(mainChatClientProvider::current); + return mainChatClientProvider.current(); } } diff --git a/base/src/main/java/ai/javaclaw/MainChatClientProvider.java b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java index c31bf495..bddc9698 100644 --- a/base/src/main/java/ai/javaclaw/MainChatClientProvider.java +++ b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java @@ -1,7 +1,6 @@ package ai.javaclaw; import ai.javaclaw.configuration.ConfigurationManager; -import ai.javaclaw.configuration.ConfigurationRefreshedEvent; import ai.javaclaw.llm.ChatClientRegistry; import ai.javaclaw.llm.LlmProviderProperties; import ai.javaclaw.llm.SubagentStore; @@ -31,7 +30,6 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.DependsOn; -import org.springframework.context.event.EventListener; import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; @@ -45,9 +43,8 @@ import java.util.Set; /** - * Builds and holds the main agent {@link ChatClient} (default provider + all tools, advisors and - * subagent routing). Rebuilt when the provider configuration is hot-reloaded so changes to the - * {@code default} provider and the set of subagent providers take effect without restarting. + * Builds the main agent {@link ChatClient} (default provider + all tools, advisors and subagent + * routing). Built once per context; configuration changes take effect via a full restart. */ @Component @DependsOn({"mcpHeaderCustomizer"}) @@ -67,7 +64,7 @@ public class MainChatClientProvider { private final Resource workspace; private final List skillPaths; - private volatile ChatClient delegate; + private final ChatClient chatClient; public MainChatClientProvider(ChatClientRegistry registry, ChatMemory chatMemory, @@ -89,20 +86,12 @@ public MainChatClientProvider(ChatClientRegistry registry, this.autoDiscoveredTools = autoDiscoveredTools; this.workspace = workspace; this.skillPaths = skillPaths; - this.delegate = build(); + this.chatClient = build(); } - /** The current main {@link ChatClient}. Accessed via {@link ai.javaclaw.llm.DelegatingChatClient}. */ + /** The main {@link ChatClient}, built at construction. */ public ChatClient current() { - return delegate; - } - - @EventListener - public void onConfigurationRefreshed(ConfigurationRefreshedEvent event) { - if (event.changedKeys().isEmpty() || event.hasChangeUnder("agent.llm.providers")) { - log.info("Rebuilding main chat client after configuration refresh"); - this.delegate = build(); - } + return chatClient; } private ChatClient build() { diff --git a/base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java b/base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java deleted file mode 100644 index 662cee38..00000000 --- a/base/src/main/java/ai/javaclaw/configuration/ConfigurationRebinder.java +++ /dev/null @@ -1,151 +0,0 @@ -package ai.javaclaw.configuration; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.bind.Bindable; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.env.YamlPropertySourceLoader; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.event.EventListener; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.MutablePropertySources; -import org.springframework.core.env.PropertySource; -import org.springframework.core.io.FileSystemResource; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Makes runtime configuration changes take effect without restarting the whole Spring context. - *

- * When the configuration file changes ({@link ConfigurationChangedEvent}) this component: - *

    - *
  1. reloads the file into the running {@link ConfigurableEnvironment} with the highest - * precedence, so the new values shadow the stale startup values;
  2. - *
  3. rebinds every {@code @ConfigurationProperties} bean from the refreshed environment - * (immutable / record-based beans are re-instantiated and swapped in the bean factory);
  4. - *
  5. publishes a {@link ConfigurationRefreshedEvent} so beans that hold runtime state - * (channels, the agent's chat client, ...) can re-initialise themselves.
  6. - *
- * This is a deliberately lightweight stand-in for Spring Cloud Context's - * {@code ConfigurationPropertiesRebinder}, which is not on the classpath. - */ -@Component -public class ConfigurationRebinder { - - private static final Logger log = LoggerFactory.getLogger(ConfigurationRebinder.class); - - /** Name of the property source we (re)inject on every reload. */ - static final String RUNTIME_PROPERTY_SOURCE = "javaclaw-runtime-config"; - - private final ConfigurableApplicationContext applicationContext; - private final ConfigurableEnvironment environment; - private final ConfigurationManager configurationManager; - private final ApplicationEventPublisher eventPublisher; - - public ConfigurationRebinder(ConfigurableApplicationContext applicationContext, - ConfigurableEnvironment environment, - ConfigurationManager configurationManager, - ApplicationEventPublisher eventPublisher) { - this.applicationContext = applicationContext; - this.environment = environment; - this.configurationManager = configurationManager; - this.eventPublisher = eventPublisher; - } - - @EventListener - public void onConfigurationChanged(ConfigurationChangedEvent event) { - rebind(event.changedKeys()); - } - - /** - * Reloads the environment, rebinds all {@code @ConfigurationProperties} beans and announces the - * refresh. Safe to call directly (e.g. from tests). - */ - public synchronized void rebind(Set changedKeys) { - reloadEnvironment(); - rebindConfigurationProperties(); - log.info("Configuration reloaded and rebound ({} changed key(s)): {}", changedKeys.size(), changedKeys); - eventPublisher.publishEvent(new ConfigurationRefreshedEvent(this, changedKeys)); - } - - private void reloadEnvironment() { - Path path = configurationManager.getConfigPath(); - if (path == null || !Files.exists(path)) { - log.debug("No configuration file at {} to reload", path); - return; - } - - try { - List> loaded = - new YamlPropertySourceLoader().load(RUNTIME_PROPERTY_SOURCE, new FileSystemResource(path)); - - MutablePropertySources sources = environment.getPropertySources(); - // Drop the previously injected runtime source(s) before re-adding the fresh ones. - sources.stream() - .map(PropertySource::getName) - .filter(name -> name.startsWith(RUNTIME_PROPERTY_SOURCE)) - .toList() - .forEach(sources::remove); - - // Add with the highest precedence so the freshly written values win over startup sources. - for (int i = loaded.size() - 1; i >= 0; i--) { - sources.addFirst(loaded.get(i)); - } - } catch (IOException e) { - throw new ConfigurationRefreshException("Failed to reload configuration from " + path, e); - } - } - - private void rebindConfigurationProperties() { - ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); - Binder binder = Binder.get(environment); - - Map beans = applicationContext.getBeansWithAnnotation(ConfigurationProperties.class); - beans.forEach((name, bean) -> rebindBean(beanFactory, binder, name, bean)); - } - - private void rebindBean(ConfigurableListableBeanFactory beanFactory, Binder binder, String name, Object bean) { - ConfigurationProperties annotation = - applicationContext.findAnnotationOnBean(name, ConfigurationProperties.class); - if (annotation == null) { - return; - } - String prefix = annotation.prefix().isEmpty() ? annotation.value() : annotation.prefix(); - - try { - if (bean.getClass().isRecord()) { - // Records (and other immutable, constructor-bound beans) cannot be mutated in place; - // re-create the instance and swap it into the singleton registry. - Object rebound = binder.bind(prefix, Bindable.of(bean.getClass())).orElse(null); - if (rebound != null && !rebound.equals(bean)) { - if (beanFactory instanceof DefaultSingletonBeanRegistry singletonRegistry) { - singletonRegistry.destroySingleton(name); - } - beanFactory.registerSingleton(name, rebound); - log.debug("Rebound (replaced) immutable @ConfigurationProperties bean '{}'", name); - } - } else { - binder.bind(prefix, Bindable.ofInstance(bean)); - log.debug("Rebound @ConfigurationProperties bean '{}' in place", name); - } - } catch (RuntimeException e) { - log.warn("Failed to rebind @ConfigurationProperties bean '{}' (prefix '{}')", name, prefix, e); - } - } - - static class ConfigurationRefreshException extends RuntimeException { - ConfigurationRefreshException(String message, Throwable cause) { - super(message, cause); - } - } -} diff --git a/base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java b/base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java deleted file mode 100644 index 402f5222..00000000 --- a/base/src/main/java/ai/javaclaw/configuration/ConfigurationRefreshedEvent.java +++ /dev/null @@ -1,43 +0,0 @@ -package ai.javaclaw.configuration; - -import org.springframework.context.ApplicationEvent; - -import java.util.Set; - -/** - * Published after the running {@link org.springframework.core.env.Environment} has been reloaded - * and all {@code @ConfigurationProperties} beans have been rebound from the freshly written - * configuration. - *

- * Beans that perform runtime work based on configuration (channels opening a connection, the agent - * building its chat client, ...) should listen for this event and re-initialise their internal state - * so that configuration changes take effect without restarting the whole application. - *

- * - * @see ConfigurationRebinder - */ -public class ConfigurationRefreshedEvent extends ApplicationEvent { - - private final Set changedKeys; - - public ConfigurationRefreshedEvent(Object source, Set changedKeys) { - super(source); - this.changedKeys = changedKeys == null ? Set.of() : Set.copyOf(changedKeys); - } - - /** - * The set of fully-qualified property keys (dotted notation) that changed in this refresh, - * e.g. {@code "agent.channels.telegram.token"}. - */ - public Set changedKeys() { - return changedKeys; - } - - /** - * Whether any changed key starts with the given prefix. Allows a listener to react only to the - * configuration it cares about, e.g. {@code hasChangeUnder("agent.channels.telegram")}. - */ - public boolean hasChangeUnder(String prefix) { - return changedKeys.stream().anyMatch(key -> key.equals(prefix) || key.startsWith(prefix + ".")); - } -} diff --git a/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java b/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java index 2ac66ea5..9f940677 100644 --- a/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java +++ b/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java @@ -1,6 +1,5 @@ package ai.javaclaw.llm; -import ai.javaclaw.configuration.ConfigurationRefreshedEvent; import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -9,7 +8,6 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; -import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.util.List; @@ -20,7 +18,8 @@ /** * Default {@link ChatClientRegistry}. Builds one {@link ChatModel} per configured provider via the - * {@link ChatModelFactory} SPI and rebuilds them when the provider configuration is hot-reloaded. + * {@link ChatModelFactory} SPI. Built once per context; configuration changes take effect via a + * full application restart. */ @Component public class DefaultChatClientRegistry implements ChatClientRegistry { @@ -46,14 +45,6 @@ public DefaultChatClientRegistry(List factories, rebuild(); } - @EventListener - public void onConfigurationRefreshed(ConfigurationRefreshedEvent event) { - if (event.changedKeys().isEmpty() || event.hasChangeUnder("agent.llm.providers")) { - log.info("Rebuilding chat clients after configuration refresh"); - rebuild(); - } - } - public final synchronized void rebuild() { Map configured = properties.getProviders(); models.clear(); diff --git a/base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java b/base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java deleted file mode 100644 index 64027e5f..00000000 --- a/base/src/main/java/ai/javaclaw/llm/DelegatingChatClient.java +++ /dev/null @@ -1,44 +0,0 @@ -package ai.javaclaw.llm; - -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.prompt.Prompt; - -import java.util.function.Supplier; - -/** - * A {@link ChatClient} that forwards every call to a delegate resolved on each invocation. This lets - * the main agent keep a single injected {@code ChatClient} reference while the underlying client is - * rebuilt (e.g. when the default provider's configuration changes) without restarting the context. - */ -public class DelegatingChatClient implements ChatClient { - - private final Supplier delegate; - - public DelegatingChatClient(Supplier delegate) { - this.delegate = delegate; - } - - private ChatClient delegate() { - return delegate.get(); - } - - @Override - public ChatClientRequestSpec prompt() { - return delegate().prompt(); - } - - @Override - public ChatClientRequestSpec prompt(String content) { - return delegate().prompt(content); - } - - @Override - public ChatClientRequestSpec prompt(Prompt prompt) { - return delegate().prompt(prompt); - } - - @Override - public Builder mutate() { - return delegate().mutate(); - } -} From 7c324219478b5e76946b51224da6896ebeb1f154 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 4 Jul 2026 14:20:08 +0300 Subject: [PATCH 08/11] Restore /settings/agents page route The agents page mapping was lost when SettingsController was removed; serve the view from IndexController so the page renders again. --- app/src/main/java/ai/javaclaw/api/IndexController.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/ai/javaclaw/api/IndexController.java b/app/src/main/java/ai/javaclaw/api/IndexController.java index 383acefe..c495f952 100644 --- a/app/src/main/java/ai/javaclaw/api/IndexController.java +++ b/app/src/main/java/ai/javaclaw/api/IndexController.java @@ -22,4 +22,9 @@ public String index() { } return "redirect:/onboarding/"; } + + @GetMapping("/settings/agents") + public String agents() { + return "settings/agents"; + } } From a0d9d5a5c3a0c2682cd2425a899f5a24f99812f7 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 4 Jul 2026 19:50:27 +0300 Subject: [PATCH 09/11] Fix home redirect to detect configured provider Onboarding writes the provider under agent.llm.providers.default, not spring.ai.model.chat, so the index redirect never saw a configured model. Gate on the default provider entry instead. --- app/src/main/java/ai/javaclaw/api/IndexController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/ai/javaclaw/api/IndexController.java b/app/src/main/java/ai/javaclaw/api/IndexController.java index c495f952..8a237ece 100644 --- a/app/src/main/java/ai/javaclaw/api/IndexController.java +++ b/app/src/main/java/ai/javaclaw/api/IndexController.java @@ -15,9 +15,9 @@ public IndexController(Environment environment) { @GetMapping({"/", "/index"}) public String index() { - String provider = environment.getProperty("spring.ai.model.chat", "unknown"); + boolean providerConfigured = environment.getProperty("agent.llm.providers.default.provider") != null; boolean onboardingCompleted = environment.getProperty("agent.onboarding.completed", Boolean.class, false); - if (!"unknown".equals(provider) || onboardingCompleted) { + if (providerConfigured || onboardingCompleted) { return "redirect:/chat"; } return "redirect:/onboarding/"; From 99fc74cbad791ef7bc83d2da6a589a9810f9d974 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 4 Jul 2026 19:53:53 +0300 Subject: [PATCH 10/11] Fix onboarding provider prefill fallback Onboarding stores the provider under agent.llm.providers.default, so the session fallbacks that read spring.ai.model.chat never pre-selected the configured provider. Read the default provider entry instead. --- .../java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java | 2 +- .../ai/javaclaw/onboarding/steps/S3_CredentialsStep.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java b/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java index c4317344..0dc56f5f 100644 --- a/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java +++ b/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java @@ -41,7 +41,7 @@ public S2_ProviderStep(AgentOnboardingProviders agentOnboardingProviders, Enviro @Override public void prepareModel(Map session, Map model) { model.put("providers", agentOnboardingProviders.getAll()); - model.put("selectedProvider", session.getOrDefault(SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", ""))); + model.put("selectedProvider", session.getOrDefault(SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", ""))); } @Override diff --git a/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java b/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java index ab764991..f02945bc 100644 --- a/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java +++ b/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java @@ -33,7 +33,7 @@ public S3_CredentialsStep(Environment env, AgentOnboardingProviders agentOnboard @Override public void prepareModel(Map session, Map model) { - String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")); + String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", "")); AgentOnboardingProvider provider = agentOnboardingProviders.findById(providerId).orElse(null); if (provider == null) return; @@ -52,7 +52,7 @@ public void prepareModel(Map session, Map model) @Override public String processStep(Map formParams, Map session) { - String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")); + String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", "")); AgentOnboardingProvider provider = agentOnboardingProviders.findById(providerId).orElse(null); if (provider == null) { return "Provider selection is missing. Please go back and select a provider."; @@ -83,7 +83,7 @@ public String processStep(Map formParams, Map se } AgentOnboardingProvider getAgentProvider(Map session) { - String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")); + String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", "")); return agentOnboardingProviders.getById(providerId); } } From 3a923fa4fdf3f4edd953c6d9022fcadcf812d3ff Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Sat, 4 Jul 2026 20:11:50 +0300 Subject: [PATCH 11/11] Use real provider config keys in credentials step The credentials step read and displayed spring.ai..* keys, but onboarding saves under agent.llm.providers.default. Read and show the actual keys, and drop the now-unused createPropertyKey/saveProperty helpers. --- .../onboarding/steps/S3_CredentialsStep.java | 14 ++++++++++---- .../onboarding/AgentOnboardingProvider.java | 10 ---------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java b/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java index f02945bc..83af6d93 100644 --- a/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java +++ b/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java @@ -1,5 +1,6 @@ package ai.javaclaw.onboarding.steps; +import ai.javaclaw.llm.LlmProviderProperties; import ai.javaclaw.onboarding.AgentOnboardingProvider; import ai.javaclaw.onboarding.AgentOnboardingProvider.SystemWideToken; import ai.javaclaw.onboarding.AgentOnboardingProviders; @@ -37,13 +38,18 @@ public void prepareModel(Map session, Map model) AgentOnboardingProvider provider = agentOnboardingProviders.findById(providerId).orElse(null); if (provider == null) return; + // Onboarding configures the single "default" provider under agent.llm.providers.default. + String base = "agent.llm.providers." + LlmProviderProperties.DEFAULT_PROVIDER_NAME; + String modelKey = base + ".model"; + String apiKeyKey = base + ".api-key"; + String currentModel = (String) session.get(S2_ProviderStep.SESSION_MODEL); - String existingModel = env.getProperty(provider.createPropertyKey("chat.options.model"), ""); - String existingApiKey = env.getProperty(provider.createPropertyKey("api-key"), ""); + String existingModel = env.getProperty(modelKey, ""); + String existingApiKey = env.getProperty(apiKeyKey, ""); model.put("selectedProvider", provider.getId()); model.put("providerLabel", provider.getLabel()); - model.put("providerApiPropertyKey", provider.createPropertyKey("api-key")); - model.put("chatModelPropertyKey", provider.createPropertyKey("chat.options.model")); + model.put("providerApiPropertyKey", apiKeyKey); + model.put("chatModelPropertyKey", modelKey); model.put("requiresApiKey", provider.requiresApiKey()); model.put("apiKey", session.getOrDefault(S2_ProviderStep.SESSION_API_KEY, existingApiKey)); model.put("model", currentModel != null && !currentModel.isBlank() ? currentModel : (!existingModel.isBlank() ? existingModel : provider.defaultModel())); diff --git a/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProvider.java b/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProvider.java index e4919d67..b924d604 100644 --- a/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProvider.java +++ b/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProvider.java @@ -1,6 +1,5 @@ package ai.javaclaw.onboarding; -import java.util.Map; import java.util.Optional; public interface AgentOnboardingProvider { @@ -19,14 +18,5 @@ default Optional systemWideToken() { return Optional.empty(); } - default String createPropertyKey(String propertySuffix) { - return "spring.ai." + getId() + "." + propertySuffix; - } - - default void saveProperty(Map properties, String propertySuffix, String value) { - if (value == null || value.isBlank()) return; - properties.put(createPropertyKey(propertySuffix), value); - } - record SystemWideToken(String name, String token) {} }