diff --git a/app/src/main/java/ai/javaclaw/JavaClawApplication.java b/app/src/main/java/ai/javaclaw/JavaClawApplication.java index e396ce6c..27eb7d39 100644 --- a/app/src/main/java/ai/javaclaw/JavaClawApplication.java +++ b/app/src/main/java/ai/javaclaw/JavaClawApplication.java @@ -45,7 +45,7 @@ public void run(ApplicationArguments args) throws Exception { @EventListener public void on(ConfigurationChangedEvent configurationChangedEvent) { ApplicationArguments args = applicationContext.getBean(ApplicationArguments.class); - + Thread thread = new Thread(() -> { try { Thread.sleep(2000); diff --git a/app/src/main/java/ai/javaclaw/api/IndexController.java b/app/src/main/java/ai/javaclaw/api/IndexController.java index 383acefe..8a237ece 100644 --- a/app/src/main/java/ai/javaclaw/api/IndexController.java +++ b/app/src/main/java/ai/javaclaw/api/IndexController.java @@ -15,11 +15,16 @@ 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/"; } + + @GetMapping("/settings/agents") + public String agents() { + return "settings/agents"; + } } 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..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 @@ -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; @@ -40,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 @@ -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/onboarding/steps/S3_CredentialsStep.java b/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java index ab764991..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; @@ -33,17 +34,22 @@ 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; + // 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())); @@ -52,7 +58,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 +89,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); } } 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..276ae910 --- /dev/null +++ b/app/src/main/resources/templates/settings/agents.html.peb @@ -0,0 +1,300 @@ +{% 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/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..fb2b57d3 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -1,20 +1,8 @@ package ai.javaclaw; -import ai.javaclaw.configuration.ConfigurationManager; 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 +10,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 +39,24 @@ 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}, 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 - @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 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..bddc9698 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/MainChatClientProvider.java @@ -0,0 +1,184 @@ +package ai.javaclaw; + +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.llm.ChatClientRegistry; +import ai.javaclaw.llm.LlmProviderProperties; +import ai.javaclaw.llm.SubagentStore; +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.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 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"}) +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 SubagentStore subagentStore; + private final Set> autoDiscoveredTools; + private final Resource workspace; + private final List skillPaths; + + private final ChatClient chatClient; + + public MainChatClientProvider(ChatClientRegistry registry, + ChatMemory chatMemory, + ObjectProvider toolSearchAdvisorProvider, + SyncMcpToolCallbackProvider mcpToolProvider, + TaskManager taskManager, + ConfigurationManager configurationManager, + SubagentStore subagentStore, + 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.subagentStore = subagentStore; + this.autoDiscoveredTools = autoDiscoveredTools; + this.workspace = workspace; + this.skillPaths = skillPaths; + this.chatClient = build(); + } + + /** The main {@link ChatClient}, built at construction. */ + public ChatClient current() { + return chatClient; + } + + 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 = 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)); + } + 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/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..9f940677 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/DefaultChatClientRegistry.java @@ -0,0 +1,138 @@ +package ai.javaclaw.llm; + +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.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. Built once per context; configuration changes take effect via a + * full application restart. + */ +@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(); + } + + 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/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/SubagentStore.java b/base/src/main/java/ai/javaclaw/llm/SubagentStore.java new file mode 100644 index 00000000..204d3fe3 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/llm/SubagentStore.java @@ -0,0 +1,135 @@ +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.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; + +/** + * 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 static final Logger log = LoggerFactory.getLogger(SubagentStore.class); + private static final String AGENTS_SUBDIRECTORY = "agents"; + + private final Resource workspace; + + public SubagentStore(@Value("${agent.workspace:file:./workspace/}") Resource workspace) { + this.workspace = workspace; + } + + /** + * @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 = 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 agents", 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 = agentsDirectory(); + if (dir == null) { + throw new IOException("Agents 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); + } + + /** 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); + 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) { + log.warn("Failed to read agent file {}: {}", file, e.getMessage()); + return Optional.empty(); + } + } + + private Path fileFor(String name) { + Path dir = agentsDirectory(); + return dir == null ? null : dir.resolve(name + ".md"); + } +} 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) {} } 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(); + } +}