diff --git a/ai-providers/ai-skills/pom.xml b/ai-providers/ai-skills/pom.xml new file mode 100644 index 00000000..574b201e --- /dev/null +++ b/ai-providers/ai-skills/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + dev.snowdrop.mtool.ai + ai-providers-parent + 1.0.5-SNAPSHOT + + + ai-skills + Migration Tool :: AI :: Agent skills + + + 21 + 21 + UTF-8 + + + + + + io.quarkus + quarkus-picocli + + + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core + 999-SNAPSHOT + + + io.quarkiverse.langchain4j + quarkus-langchain4j-skills + 999-SNAPSHOT + + + dev.snowdrop.mtool.ai + vertex-ai-anthropic + 1.0.5-SNAPSHOT + + + + dev.langchain4j + langchain4j-vertex-ai-anthropic + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + true + + + + build + generate-code + + none + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 25 + 25 + --enable-preview + + + + + + \ No newline at end of file diff --git a/ai-providers/ai-skills/src/main/java/AiHttpAgent.java b/ai-providers/ai-skills/src/main/java/AiHttpAgent.java new file mode 100644 index 00000000..52e86a5d --- /dev/null +++ b/ai-providers/ai-skills/src/main/java/AiHttpAgent.java @@ -0,0 +1,260 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25+ +//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.2 + +import module java.base; + +import static java.lang.System.getenv; +import static java.nio.file.Files.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * nanocode - minimal claude code alternative. Original: + * https://github.com/1rgs/nanocode + */ + +static final ObjectMapper JSON = new ObjectMapper(); +static final String OPENROUTER_KEY = getenv("OPENROUTER_API_KEY"); +static final String API_URL = OPENROUTER_KEY != null + ? "https://openrouter.ai/api/v1/messages" + : "https://api.anthropic.com/v1/messages"; +static final String MODEL = Optional.ofNullable(getenv("MODEL")) + .orElse(OPENROUTER_KEY != null ? "anthropic/claude-opus-4.6" : "claude-opus-4-6"); + + static final String RESET = "\033[0m", BOLD = "\033[1m", DIM = "\033[2m"; + static final String BLUE = "\033[34m", CYAN = "\033[36m", GREEN = "\033[32m", RED = "\033[31m"; + +// --- Tools --- + +static String toolRead(JsonNode args) throws IOException { + var lines = readAllLines(Path.of(args.get("path").asText())); + int offset = args.path("offset").asInt(0), limit = args.path("limit").asInt(lines.size()); + var sb = new StringBuilder(); + for (int i = offset; i < Math.min(offset + limit, lines.size()); i++) + sb.append("%4d| %s%n".formatted(i + 1, lines.get(i))); + return sb.toString(); +} + +static String toolWrite(JsonNode args) throws IOException { + writeString(Path.of(args.get("path").asText()), args.get("content").asText()); + return "ok"; +} + +static String toolEdit(JsonNode args) throws IOException { + var path = Path.of(args.get("path").asText()); + var text = readString(path); + var old = args.get("old").asText(); + var repl = args.get("new").asText(); + if (!text.contains(old)) + return "error: old_string not found"; + int count = (text.length() - text.replace(old, "").length()) / old.length(); + if (!args.path("all").asBoolean() && count > 1) + return "error: old_string appears " + count + " times, must be unique (use all=true)"; + writeString(path, args.path("all").asBoolean() + ? text.replace(old, repl) + : text.replaceFirst(Pattern.quote(old), Matcher.quoteReplacement(repl))); + return "ok"; +} + +static String toolGlob(JsonNode args) throws IOException { + var base = Path.of(args.path("path").asText(".")); + var matcher = FileSystems.getDefault().getPathMatcher("glob:" + base + "/" + args.get("pat").asText()); + if (!exists(base)) + return "none"; + try (var walk = walk(base)) { + var files = walk.filter(Files::isRegularFile).filter(matcher::matches) + .sorted((a, b) -> { + try { + return getLastModifiedTime(b).compareTo(getLastModifiedTime(a)); + } catch (IOException e) { + return 0; + } + }) + .map(Path::toString).toList(); + return files.isEmpty() ? "none" : String.join("\n", files); + } +} + +static String toolGrep(JsonNode args) throws IOException { + var pattern = Pattern.compile(args.get("pat").asText()); + var base = Path.of(args.path("path").asText(".")); + var hits = new ArrayList(); + try (var walk = walk(base)) { + walk.filter(Files::isRegularFile).takeWhile(_ -> hits.size() < 50).forEach(file -> { + try { + var lines = readAllLines(file); + for (int i = 0; i < lines.size() && hits.size() < 50; i++) + if (pattern.matcher(lines.get(i)).find()) + hits.add(file + ":" + (i + 1) + ":" + lines.get(i)); + } catch (Exception e) { + /* skip */ } + }); + } + return hits.isEmpty() ? "none" : String.join("\n", hits); +} + +static String toolBash(JsonNode args) throws Exception { + var proc = new ProcessBuilder("sh", "-c", args.get("cmd").asText()).redirectErrorStream(true).start(); + var out = new ArrayList(); + try (var r = new BufferedReader(new InputStreamReader(proc.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + System.out.println(" " + DIM + "│ " + line + RESET); + out.add(line); + } + } + if (!proc.waitFor(30, TimeUnit.SECONDS)) { + proc.destroyForcibly(); + out.add("(timed out after 30s)"); + } + return out.isEmpty() ? "(empty)" : String.join("\n", out); +} + +static String runTool(String name, JsonNode args) { + try { + return switch (name) { + case "read" -> toolRead(args); + case "write" -> toolWrite(args); + case "edit" -> toolEdit(args); + case "glob" -> toolGlob(args); + case "grep" -> toolGrep(args); + case "bash" -> toolBash(args); + default -> "error: unknown tool " + name; + }; + } catch (Exception e) { + return "error: " + e.getMessage(); + } +} + +// --- Schema --- + +static final String SCHEMA = """ + [{"name":"read","description":"Read file with line numbers (file path, not directory)","input_schema":{"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}}, + {"name":"write","description":"Write content to file","input_schema":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}, + {"name":"edit","description":"Replace old with new in file (old must be unique unless all=true)","input_schema":{"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"},"all":{"type":"boolean"}},"required":["path","old","new"]}}, + {"name":"glob","description":"Find files by pattern, sorted by mtime","input_schema":{"type":"object","properties":{"pat":{"type":"string"},"path":{"type":"string"}},"required":["pat"]}}, + {"name":"grep","description":"Search files for regex pattern","input_schema":{"type":"object","properties":{"pat":{"type":"string"},"path":{"type":"string"}},"required":["pat"]}}, + {"name":"bash","description":"Run shell command","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}},"required":["cmd"]}}]"""; + +// --- API --- + +static JsonNode callApi(ArrayNode messages, String systemPrompt) throws IOException { + var body = JSON.createObjectNode().put("model", MODEL).put("max_tokens", 8192).put("system", systemPrompt); + body.set("messages", messages); + body.set("tools", JSON.readTree(SCHEMA)); + + var conn = (HttpURLConnection) URI.create(API_URL).toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("anthropic-version", "2023-06-01"); + conn.setRequestProperty(OPENROUTER_KEY != null ? "Authorization" : "x-api-key", + OPENROUTER_KEY != null ? "Bearer " + OPENROUTER_KEY + : Optional.ofNullable(getenv("ANTHROPIC_API_KEY")).orElse("")); + + try (var os = conn.getOutputStream()) { + os.write(JSON.writeValueAsBytes(body)); + } + int status = conn.getResponseCode(); + var response = JSON.readTree(status >= 400 ? conn.getErrorStream() : conn.getInputStream()); + if (status >= 400) + throw new IOException("API error " + status + ": " + response); + return response; +} + +// --- UI --- + +static String sep() { + try { + var p = new ProcessBuilder("tput", "cols").redirectErrorStream(true).start(); + return DIM + "─".repeat(Math.min(Integer.parseInt(new String(p.getInputStream().readAllBytes()).trim()), 80)) + + RESET; + } catch (Exception e) { + return DIM + "─".repeat(80) + RESET; + } +} + +static String preview(String s, int max) { + var lines = s.split("\n"); + var p = lines[0].substring(0, Math.min(lines[0].length(), max)); + return lines.length > 1 ? p + " ... +" + (lines.length - 1) + " lines" : (lines[0].length() > max ? p + "..." : p); +} + +// --- Main --- + +void main(String[] args) throws Exception { + var cwd = System.getProperty("user.dir"); + System.out.println(BOLD + "nanocode" + RESET + " | " + DIM + MODEL + " (" + + (OPENROUTER_KEY != null ? "OpenRouter" : "Anthropic") + ") | " + cwd + RESET + "\n"); + + var messages = JSON.createArrayNode(); + var systemPrompt = "Concise coding assistant. cwd: " + cwd; + var stdin = new BufferedReader(new InputStreamReader(System.in)); + + while (true) { + try { + System.out.println(sep()); + System.out.print(BOLD + BLUE + "❯" + RESET + " "); + System.out.flush(); + var input = stdin.readLine(); + if (input == null) + break; + input = input.strip(); + System.out.println(sep()); + if (input.isEmpty()) + continue; + if (input.equals("/q") || input.equals("exit")) + break; + if (input.equals("/c")) { + messages = JSON.createArrayNode(); + System.out.println(GREEN + "⏺ Cleared" + RESET); + continue; + } + + messages.add(JSON.createObjectNode().put("role", "user").put("content", input)); + + while (true) { + var response = callApi(messages, systemPrompt); + var content = response.get("content"); + var toolResults = JSON.createArrayNode(); + + for (var block : content) { + if ("text".equals(block.get("type").asText())) + System.out.println("\n" + CYAN + "⏺" + RESET + " " + + block.get("text").asText().replaceAll("\\*\\*(.+?)\\*\\*", BOLD + "$1" + RESET)); + + if ("tool_use".equals(block.get("type").asText())) { + var name = block.get("name").asText(); + var toolArgs = block.get("input"); + var argPreview = toolArgs.fields().hasNext() ? toolArgs.fields().next().getValue().asText() + : ""; + System.out + .println("\n" + GREEN + "⏺ " + Character.toUpperCase(name.charAt(0)) + name.substring(1) + + RESET + "(" + DIM + argPreview.substring(0, Math.min(50, argPreview.length())) + + RESET + ")"); + + var result = runTool(name, toolArgs); + System.out.println(" " + DIM + "⎿ " + preview(result, 60) + RESET); + + toolResults.add(JSON.createObjectNode().put("type", "tool_result") + .put("tool_use_id", block.get("id").asText()).put("content", result)); + } + } + + messages.add(JSON.createObjectNode().put("role", "assistant").set("content", content)); + if (toolResults.isEmpty()) + break; + messages.add(JSON.createObjectNode().put("role", "user").set("content", toolResults)); + } + System.out.println(); + } catch (Exception e) { + if (e instanceof EOFException) + break; + System.out.println(RED + "⏺ Error: " + e.getMessage() + RESET); + } + } +} diff --git a/ai-providers/ai-skills/src/main/java/Assistant.java b/ai-providers/ai-skills/src/main/java/Assistant.java new file mode 100644 index 00000000..2e75abaa --- /dev/null +++ b/ai-providers/ai-skills/src/main/java/Assistant.java @@ -0,0 +1,3 @@ +public interface Assistant { + String chat(String message); +} \ No newline at end of file diff --git a/ai-providers/ai-skills/src/main/java/ScannerTool.java b/ai-providers/ai-skills/src/main/java/ScannerTool.java new file mode 100644 index 00000000..f6ffcbf4 --- /dev/null +++ b/ai-providers/ai-skills/src/main/java/ScannerTool.java @@ -0,0 +1,160 @@ +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class ScannerTool { + + public ScannerTool() {} + + @Tool("Read file with line numbers (file path, not directory)") + public String readFile( + @P("The relative path to the file from the project root") String path, + @P("The line offset to start reading from (0-based). Defaults to 0") String offset, + @P("The number of lines to read. Defaults to all lines") String limit) throws IOException { + + Path target = pathNormalize(path); + List lines = Files.readAllLines(target); + int off = (offset != null && !offset.isBlank()) ? Integer.parseInt(offset) : 0; + int lim = (limit != null && !limit.isBlank()) ? Integer.parseInt(limit) : lines.size(); + StringBuilder sb = new StringBuilder(); + for (int i = off; i < Math.min(off + lim, lines.size()); i++) + sb.append(String.format("%4d| %s%n", i + 1, lines.get(i))); + return sb.toString(); + } + + @Tool("Write content to file") + public String writeFile( + @P("The relative path to the file from the project root") String path, + @P("The content to write to the file") String content) throws IOException { + + Path target = pathNormalize(path); + Files.writeString(target, content); + return "ok"; + } + + @Tool("Replace old with new in file (old must be unique unless all=true)") + public String editFile( + @P("The relative path to the file from the project root") String path, + @P("The text to find and replace") String old, + @P("The replacement text") String replacement, + @P("If 'true', replace all occurrences. Defaults to false") String all) throws IOException { + + Path target = pathNormalize(path); + String text = Files.readString(target); + if (!text.contains(old)) + return "error: old_string not found"; + int count = (text.length() - text.replace(old, "").length()) / old.length(); + boolean replaceAll = "true".equalsIgnoreCase(all); + if (!replaceAll && count > 1) + return "error: old_string appears " + count + " times, must be unique (use all=true)"; + Files.writeString(target, replaceAll + ? text.replace(old, replacement) + : text.replaceFirst(Pattern.quote(old), Matcher.quoteReplacement(replacement))); + return "ok"; + } + + @Tool("Find files by glob pattern, sorted by modification time") + public String globFiles( + @P("The glob pattern (e.g., '**/*.java', '*.xml')") String pattern, + @P("The relative directory to search from (use '.' for root). Defaults to '.'") String path) + throws IOException { + + String dir = (path != null && !path.isBlank()) ? path : "."; + Path base = pathNormalize(dir); + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + base + "/" + pattern); + if (!Files.exists(base)) + return "none"; + try (Stream walk = Files.walk(base)) { + List files = walk.filter(Files::isRegularFile) + .filter(matcher::matches) + .sorted((Path a, Path b) -> { + try { + return Files.getLastModifiedTime(b).compareTo(Files.getLastModifiedTime(a)); + } catch (IOException e) { + return 0; + } + }) + .map(p -> base.relativize(p).toString()) + .collect(Collectors.toList()); + return files.isEmpty() ? "none" : String.join("\n", files); + } + } + + @Tool("Recursively searches for a regex pattern within the project files") + public String grepProject( + @P("The text or regex pattern to search for (e.g., '@SpringBootApplication')") String pattern, + @P("The relative directory to start the search from (use '.' for everywhere)") String startDir) + throws IOException { + + Path startPath = pathNormalize(startDir); + Pattern compiled = Pattern.compile(pattern); + List hits = new ArrayList<>(); + try (Stream walk = Files.walk(startPath)) { + List files = walk.filter(Files::isRegularFile).collect(Collectors.toList()); + for (Path file : files) { + if (isBlacklisted(file)) + continue; + if (hits.size() >= 50) + break; + try { + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size() && hits.size() < 50; i++) + if (compiled.matcher(lines.get(i)).find()) + hits.add(startPath.relativize(file) + ":" + (i + 1) + ": " + lines.get(i).trim()); + } catch (Exception e) { + /* skip binary or unreadable files */ + } + } + } + return hits.isEmpty() ? "none" : String.join("\n", hits); + } + + @Tool("Run shell command") + public String bash( + @P("The shell command to execute") String command) throws Exception { + + Process proc = new ProcessBuilder("sh", "-c", command) + .redirectErrorStream(true) + .start(); + List out = new ArrayList<>(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) + out.add(line); + } + if (!proc.waitFor(30, TimeUnit.SECONDS)) { + proc.destroyForcibly(); + out.add("(timed out after 30s)"); + } + return out.isEmpty() ? "(empty)" : String.join("\n", out); + } + + /** + * Normalize the path of the AI's tool request + */ + private Path pathNormalize(String path) { + return Path.of(path).normalize(); + } + + /** + * Prevents the AI from wasting cycles on target/ or .git/ folders. + */ + private boolean isBlacklisted(Path path) { + String p = path.toString(); + return p.contains("/target/") || p.contains("/.git/") || p.contains("/.idea/") || p.endsWith(".class"); + } +} diff --git a/ai-providers/ai-skills/src/main/java/SkillsAgent.java b/ai-providers/ai-skills/src/main/java/SkillsAgent.java new file mode 100644 index 00000000..c52a3f51 --- /dev/null +++ b/ai-providers/ai-skills/src/main/java/SkillsAgent.java @@ -0,0 +1,127 @@ + +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS dev.langchain4j:langchain4j:1.13.1 +//DEPS dev.langchain4j:langchain4j-anthropic:1.13.1 +//DEPS dev.langchain4j:langchain4j-skills:1.13.0-beta23 +//DEPS dev.snowdrop.mtool.ai:vertex-ai-anthropic:1.0.5-SNAPSHOT +////DEPS dev.langchain4j:langchain4j-vertex-ai-anthropic:1.13.1-beta23 +//SOURCES Assistant.java +//SOURCES ScannerTool.java +//DEPS org.slf4j:slf4j-simple:2.0.17 +//RUNTIME_OPTIONS -Dorg.slf4j.simpleLogger.defaultLogLevel=info -Dorg.slf4j.simpleLogger.log.dev.langchain4j=debug + +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.service.AiServices; +import dev.langchain4j.skills.FileSystemSkillLoader; +import dev.langchain4j.skills.Skills; +//import dev.langchain4j.model.vertexai.anthropic.VertexAiAnthropicChatModel; +import io.quarkiverse.langchain4j.vertexai.runtime.anthropic.VertexAiAnthropicChatModel; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Scanner; +import java.util.logging.Logger; + +public class SkillsAgent { + + private static Logger logger = Logger.getLogger(SkillsAgent.class.getName()); + + public static void main(String[] args) { + /* + String projectId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID", "dummy"); + String location = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION", "dummy"); + String modelId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MODEL_ID", "claude-opus-4-6"); + + validateRequired(projectId, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID"); + validateRequired(location, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION"); + + ChatModel model = VertexAiAnthropicChatModel.builder() + .project(projectId) + .location(location) + .modelName(modelId) + .maxTokens(100000) + .logRequests(true) + .logResponses(true) + .build(); + */ + + String projectId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID", "dummy"); + String location = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION", "dummy"); + String modelId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MODEL_ID", "claude-opus-4-6"); + String publisher = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PUBLISHER", "anthropic"); + int duration = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_DURATION", "30")); + int maxTokens = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MAX_TOKENS", "1000")); + boolean logRequests = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_REQUESTS", "false")); + boolean logResponses = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_RESPONSES", "false")); + + validateRequired(projectId, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID"); + validateRequired(location, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION"); + + ChatModel model = VertexAiAnthropicChatModel.builder() + .projectId(projectId) + .location(location) + .modelId(modelId) + .publisher(publisher) + .maxOutputTokens(maxTokens) + .timeout(Duration.ofSeconds(duration)) + .logRequests(logRequests) + .logResponses(logResponses) + .logCurl(false) + .build(); + + String skillsPath = (args.length > 0 && !args[0].isBlank()) ? args[0] : getEnv("SKILLS_PATH", null); + if (skillsPath == null || skillsPath.isBlank()) { + throw new IllegalStateException( + "CRITICAL ERROR: Skills path must be provided as the first argument or via the 'SKILLS_PATH' environment variable."); + } + + Skills skills = Skills.from(FileSystemSkillLoader.loadSkill(Path.of(skillsPath))); + + Assistant agent = AiServices.builder(Assistant.class) + .chatModel(model) + .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) + .tools(new ScannerTool()) + .toolProvider(skills.toolProvider()) + .maxSequentialToolsInvocations(100) + .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills() + + "\nWhen the user's request relates to one of these skills, activate it first.") + .build(); + + Scanner scanner = new Scanner(System.in); + // Migrate Spring Boot application to Quarkus. Use the automated_responses.yml to get answers using the match_query and answers. The question/answer are defined in the YAML file under intent_responses + logger.info("=== Migration Agent Online ==="); + while (true) { + System.out.print("User: "); + String input = scanner.nextLine(); + if ("exit".equalsIgnoreCase(input)) + break; + + String response = agent.chat(input); + System.out.println("AI: " + response); + } + + + //System.out.println("=== Migration Agent Online ==="); + //String response = agent.chat("Migrate Spring Boot application to Quarkus. Use the automated_responses.yml to get answers using the match_query and answer within this file"); + //logger.info("Agent: " + response); + } + + /** + * Helper to get Env Var or return a default. + */ + private static String getEnv(String name, String defaultValue) { + String val = System.getenv(name); + return (val != null && !val.isBlank()) ? val : defaultValue; + } + + /** + * Helper to enforce required fields. + */ + private static void validateRequired(String value, String envName) { + if (value == null || value.isBlank() || value.equals("dummy")) { + throw new IllegalStateException( + "CRITICAL ERROR: The environment variable '" + envName + "' is required but not set."); + } + } +} \ No newline at end of file diff --git a/ai-providers/ai-skills/src/main/java/SkillsQuarkusAgent.java b/ai-providers/ai-skills/src/main/java/SkillsQuarkusAgent.java new file mode 100644 index 00000000..c0657d0f --- /dev/null +++ b/ai-providers/ai-skills/src/main/java/SkillsQuarkusAgent.java @@ -0,0 +1,92 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS io.quarkus:quarkus-picocli:3.31.3 +//DEPS io.quarkiverse.langchain4j:quarkus-langchain4j-core:999-SNAPSHOT +//DEPS io.quarkiverse.langchain4j:quarkus-langchain4j-skills:999-SNAPSHOT +//DEPS dev.snowdrop.mtool.ai:vertex-ai-anthropic:1.0.5-SNAPSHOT +//SOURCES ScannerTool.java + +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.service.AiServices; +import dev.langchain4j.skills.FileSystemSkillLoader; +import dev.langchain4j.skills.Skills; +import io.quarkiverse.langchain4j.vertexai.runtime.anthropic.VertexAiAnthropicChatModel; +import picocli.CommandLine; + +import java.nio.file.Path; +import java.time.Duration; + +@CommandLine.Command +public class SkillsQuarkusAgent implements Runnable { + + @CommandLine.Option( + names = {"-s", "--skill"}, + description = "Path directory of the skill to be executed", + required = true + ) + String skillsPath; + + @Override + public void run() { + String projectId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID", "dummy"); + String location = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION", "dummy"); + String modelId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MODEL_ID", "claude-opus-4-6"); + String publisher = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PUBLISHER", "anthropic"); + int duration = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_DURATION", "300")); + int maxTokens = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MAX_TOKENS", "1000")); + boolean logRequests = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_REQUESTS", "false")); + boolean logResponses = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_RESPONSES", "false")); + + validateRequired(projectId, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID"); + validateRequired(location, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION"); + + ChatModel model = VertexAiAnthropicChatModel.builder() + .projectId(projectId) + .location(location) + .modelId(modelId) + .publisher(publisher) + .maxOutputTokens(maxTokens) + .timeout(Duration.ofSeconds(duration)) + .logRequests(logRequests) + .logResponses(logResponses) + .logCurl(false) + .build(); + + Skills skills = Skills.from(FileSystemSkillLoader.loadSkill(Path.of(skillsPath))); + + Assistant agent = AiServices.builder(Assistant.class) + .chatModel(model) + .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) + .tools(new ScannerTool()) + .maxSequentialToolsInvocations(100) + .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills() + + "\nWhen the user's request relates to one of these skills, activate it first.") + .build(); + + System.out.println("=== Migration Agent Online ==="); + String response = agent.chat("Migrate Spring Boot applications to Quarkus. Use the automated_responses.yml to get answers"); + System.out.println("Agent: " + response); + } + + /** + * Helper to get Env Var or return a default. + */ + private static String getEnv(String name, String defaultValue) { + String val = System.getenv(name); + return (val != null && !val.isBlank()) ? val : defaultValue; + } + + /** + * Helper to enforce required fields. + */ + private static void validateRequired(String value, String envName) { + if (value == null || value.isBlank() || value.equals("dummy")) { + throw new IllegalStateException( + "CRITICAL ERROR: The environment variable '" + envName + "' is required but not set."); + } + } + + interface Assistant { + String chat(String message); + } +} \ No newline at end of file diff --git a/ai-providers/ai-skills/src/main/resources/application.properties b/ai-providers/ai-skills/src/main/resources/application.properties new file mode 100644 index 00000000..14cff3fe --- /dev/null +++ b/ai-providers/ai-skills/src/main/resources/application.properties @@ -0,0 +1,6 @@ +# LLM and client enabled +quarkus.langchain4j.timeout=60s +quarkus.langchain4j.log-requests=false +quarkus.langchain4j.log-responses=false +quarkus.langchain4j.chat-model.provider=vertexai-anthropic +quarkus.langchain4j.vertexai.anthropic.model-id=claude-opus-4-6 \ No newline at end of file