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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
order: 1
instructions:
ai:
- skills:
- "spring-boot-to-quarkus/check-jdk"
- "spring-boot-to-quarkus/configuration"
- tasks:
- "Add to the pom.xml file the Quarkus BOM dependency version 3.31.3 within the dependencyManagement section and the following dependencies: quarkus-arc, quarkus-core and quarkus-smallrye-openapi"
- "Remove next from the ôpm.xml the plugin org.springframework.boot:spring-boot-maven-plugin"
Expand Down
7 changes: 6 additions & 1 deletion migration-cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-openai</artifactId>
<version>1.8.4</version>
<version>${quarkus-langchain4j.version}</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-skills</artifactId>
<version>${quarkus-langchain4j.version}</version>
</dependency>

<!-- lsp4j -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import dev.snowdrop.mtool.model.transform.MigrationTasksExport;
import dev.snowdrop.mtool.transform.provider.ai.Assistant;
import dev.snowdrop.mtool.transform.provider.ai.FileSystemTool;
import dev.snowdrop.mtool.transform.provider.ai.SkillsAssistant;
import dev.snowdrop.mtool.transform.provider.impl.OpenRewriteProvider;
import dev.snowdrop.mtool.transform.provider.model.ExecutionContext;
import dev.snowdrop.mtool.transform.provider.model.ExecutionResult;
Expand Down Expand Up @@ -40,7 +41,7 @@ public class TransformCommand implements Runnable {
private boolean dryRun;

@CommandLine.Option(names = { "-p",
"--provider" }, description = "Migration provider to use (ai, openrewrite, manual). Default: from migration.provider property")
"--provider" }, description = "Migration provider to use (ai, ai-skill, openrewrite, manual). Default: from migration.provider property")
@ConfigProperty(name = "migration.provider")
private String provider;

Expand All @@ -50,9 +51,15 @@ public class TransformCommand implements Runnable {
@ConfigProperty(name = "openrewrite.maven-plugin.version")
private String openRewriteMavenPluginVersion;

@ConfigProperty(name = "quarkus.langchain4j.skills.directories")
private String aiAgentSkillsHomeDir;

@Inject
Assistant aiAssistant;

@Inject
SkillsAssistant aiSkillsAssistant;

@Inject
FileSystemTool fileSystemTool;

Expand Down Expand Up @@ -162,8 +169,8 @@ private void startTransformation() {

// Configure the Context with the information used by the Provider
fileSystemTool.setBasePath(projectPath);
ExecutionContext context = new ExecutionContext(projectPath, verbose, dryRun, provider, aiAssistant,
openRewriteMavenPluginVersion, compositeRecipeName);
ExecutionContext context = new ExecutionContext(projectPath, verbose, dryRun, provider, aiAssistant, aiSkillsAssistant,
aiAgentSkillsHomeDir, openRewriteMavenPluginVersion, compositeRecipeName);

if ("openrewrite".equals(provider)) {
// Batch all OpenRewrite tasks into a single Maven execution
Expand Down Expand Up @@ -212,7 +219,7 @@ private void executeTaskWithProvider(String taskId, MigrationTask task, Executio
var instructions = task.getRule().instructions();
boolean hasInstructions = instructions != null && switch (provider) {
case "openrewrite" -> instructions.openrewrite() != null;
case "ai" -> instructions.ai() != null;
case "ai", "ai-skills" -> instructions.ai() != null;
case "manual" -> instructions.manual() != null;
default -> false;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public class TransformationService {
public void logExecutionResult(ExecutionResult result, boolean verbose) {
if (result.success()) {
logger.infof("Task completed successfully: %s", result.message());
} else if (result.warning() != null) {
logger.warn(result.warning());
} else {
logger.errorf("Task failed: %s", result.message());
if (result.exception() != null && verbose) {
Expand Down Expand Up @@ -62,6 +64,8 @@ public ExecutionResult execute(MigrationTask task, ExecutionContext ctx) {
if (result.success()) {
return ExecutionResult.success(String.format(" %s execution completed successfully", ctx.provideType()),
allDetails);
} else if (result.warning() != null) {
return ExecutionResult.warning(String.format(" %s for provider: %s", result.warning(), ctx.provideType()));
} else {
return ExecutionResult.failure(String.format(" %s execution failed !", ctx.provideType()));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.snowdrop.mtool.transform.provider;

import dev.snowdrop.mtool.transform.provider.impl.AiProvider;
import dev.snowdrop.mtool.transform.provider.impl.AiSkillsProvider;
import dev.snowdrop.mtool.transform.provider.impl.ManualProvider;
import dev.snowdrop.mtool.transform.provider.impl.OpenRewriteProvider;

Expand All @@ -18,6 +19,7 @@ public class ProviderFactory {
static {
registerProvider(new OpenRewriteProvider());
registerProvider(new AiProvider());
registerProvider(new AiSkillsProvider());
registerProvider(new ManualProvider());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package dev.snowdrop.mtool.transform.provider;

import dev.snowdrop.mtool.transform.provider.model.ExecutionContext;
import dev.snowdrop.mtool.transform.provider.model.ExecutionResult;

public interface SkillsEnabledProvider extends MigrationProvider {
ExecutionResult execute(String skillPath, ExecutionContext context);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;

/**
* A LangChain4j tool that exposes file system read and write operations to AI agents.
Expand Down Expand Up @@ -63,7 +65,29 @@ public String readFile(@P("Path to the file to analyze") String path) {
logger.debugf("Reading file: %s", resolved);
return Files.readString(resolved);
} catch (IOException e) {
return "Error reading file: " + e.getMessage();
return String.format("Skip the path: %s as this %s", path, e.getMessage());
}
}

@Tool("Whenever you need to know the files part of an application to understand the java project structure")
public List<String> listFiles(@P("The relative path from the project root. Use '.' for the root directory. " +
"Example: 'src/main/java' or 'src/test/resources'. " +
"Do not use absolute paths or leading slashes.") String path)
throws IOException {

Path resolved = resolve(path);

if (!resolved.startsWith(basePath)) {
return List.of("Error: Access denied. You cannot look outside the project root.");
}

if (!Files.exists(resolved)) {
return List.of("Error: Directory does not exist: " + path);
}

try (var stream = Files.list(resolved)) {
return stream.map(p -> basePath.relativize(p).toString())
.collect(Collectors.toList());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package dev.snowdrop.mtool.transform.provider.ai;

import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import io.quarkiverse.langchain4j.RegisterAiService;
import io.quarkiverse.langchain4j.ToolBox;
import jakarta.enterprise.context.ApplicationScoped;

@RegisterAiService
@ApplicationScoped
public interface SkillsAssistant {
@ToolBox(FileSystemTool.class)
String chat(String message);
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private ExecutionResult executeAiInstruction(Rule.Ai ai, Rule rule, ExecutionCon
details.add("- " + task);
});

boolean success = execAiCmd(context, tasks, details);
boolean success = execAiCmd(context, tasks);
details.add("Ai cmd executed successfully");

if (success) {
Expand All @@ -84,7 +84,7 @@ private ExecutionResult executeAiInstruction(Rule.Ai ai, Rule rule, ExecutionCon
}
}

private boolean execAiCmd(ExecutionContext ctx, List<String> tasks, List<String> details) {
private boolean execAiCmd(ExecutionContext ctx, List<String> tasks) {
logger.info("Hello! I'm your AI migration assistant and I will help you move your code");

tasks.forEach(t -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package dev.snowdrop.mtool.transform.provider.impl;

import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.response.*;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.skills.FileSystemSkillLoader;
import dev.langchain4j.skills.Skills;
import dev.snowdrop.mtool.model.analyze.MigrationTask;
import dev.snowdrop.mtool.model.analyze.Rule;
import dev.snowdrop.mtool.transform.provider.MigrationProvider;
import dev.snowdrop.mtool.transform.provider.ai.FileSystemTool;
import dev.snowdrop.mtool.transform.provider.ai.SkillsAssistant;
import dev.snowdrop.mtool.transform.provider.model.ExecutionContext;
import dev.snowdrop.mtool.transform.provider.model.ExecutionResult;
import io.quarkiverse.langchain4j.vertexai.runtime.anthropic.VertexAiAnthropicChatModel;
import org.jboss.logging.Logger;

import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import static dev.langchain4j.model.LambdaStreamingResponseHandler.onPartialResponse;

public class AiSkillsProvider implements MigrationProvider {
private static final Logger logger = Logger.getLogger(AiSkillsProvider.class);
private ChatModel model;

public AiSkillsProvider() {
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.valueOf(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_REQUESTS", "false"));
Boolean logResponses = Boolean.valueOf(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_RESPONSES", "false"));

validateRequired(projectId, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID");
validateRequired(location, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION");

model = VertexAiAnthropicChatModel.builder()
.projectId(projectId)
.location(location)
.modelId(modelId)
.publisher(publisher)
.maxOutputTokens(maxTokens)
.timeout(Duration.ofSeconds(duration))
.logRequests(logRequests)
.logResponses(logResponses)
.build();
}

@Override
public String getProviderType() {
return "ai-skills";
}

@Override
public ExecutionResult execute(MigrationTask task, ExecutionContext context) {

// We only process until now only one instruction/rule !
var ai = Arrays.stream(task.getRule().instructions().ai()).findFirst().orElse(null);

assert ai != null;
if (ai.skills() == null || ai.skills().isEmpty()) {
return ExecutionResult
.warning(String.format("No AI Skills defined part of the rule: %s. Skipping", task.getRule().ruleID()));
}

try {
ExecutionResult result = executeAiInstruction(ai, task.getRule(), context);

if (!result.success()) {
return ExecutionResult.failure(result.message(), result.details(), null);
}

return ExecutionResult.success("AI SKILL execution completed successfully", result.details());
} catch (Exception e) {
logger.errorf("Error executing AI SKILL: %s", e.getMessage());
if (context.verbose()) {
e.printStackTrace();
}
return ExecutionResult.failure("Error executing AI SKILL !", e);
}
}

private ExecutionResult executeAiInstruction(Rule.Ai ai, Rule rule, ExecutionContext context) {
List<String> details = new ArrayList<>();

details.add("Processing rule : " + "" + rule.ruleID());

var skills = ai.skills();

if (skills == null || skills.isEmpty()) {
return ExecutionResult.failure(
String.format("No AI SKILL defined for rule %s, skipping", rule.ruleID()), null);
}

// Get the list of SKILL
skills.forEach(skill -> {
logger.infof("Skill: %s", skill);
details.add("- " + skill);
});

boolean success = execAiSkillCmd(context, skills);
details.add("AI SKILL cmd executed successfully");

if (success) {
return ExecutionResult.success("AI instruction executed successfully", details);
} else {
return ExecutionResult.failure("AI's chat command execution failed", details, null);
}
}

private boolean execAiSkillCmd(ExecutionContext ctx, List<String> skills) {
logger.info("Hello! I'm your AI SKILL migration assistant and I will help you moving your code");

// TODO: Have a property to set the SKILL Agent home folder
// Claude: .claude/skills

skills.forEach(s -> {
logger.infof("=== Processing SKILL: %S", s);
Skills agentSkill = Skills.from(FileSystemSkillLoader.loadSkill(Path.of(ctx.aiSkillsHomeDir(), s)));
SkillsAssistant service = AiServices.builder(SkillsAssistant.class)
.chatModel(model)
.systemMessage(agentSkill.formatAvailableSkills())
.maxSequentialToolsInvocations(100)
.build();

String response = service
.chat(String.format("Migrate the application of the current directory using the SKILL: %s", s));
logger.infof("=== Model response: %s", response);
});

return true;
}

/**
* 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.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package dev.snowdrop.mtool.transform.provider.model;

import dev.snowdrop.mtool.transform.provider.ai.Assistant;
import dev.snowdrop.mtool.transform.provider.ai.SkillsAssistant;

import java.nio.file.Path;

/**
* Context information for provider execution including project settings and configuration.
*/
public record ExecutionContext(Path projectPath, boolean verbose, boolean dryRun, String provideType,
Assistant assistant, String openRewriteMavenPluginVersion, String compositeRecipeName) {
Assistant assistant, SkillsAssistant aiSkillsAssistant, String aiSkillsHomeDir, String openRewriteMavenPluginVersion,
String compositeRecipeName) {
}
Loading
Loading