diff --git a/src/main/kotlin/nl/trivion/softwarefabric/agents/DesignerAgent.kt b/src/main/kotlin/nl/trivion/softwarefabric/agents/DesignerAgent.kt new file mode 100644 index 0000000..8ca6321 --- /dev/null +++ b/src/main/kotlin/nl/trivion/softwarefabric/agents/DesignerAgent.kt @@ -0,0 +1,200 @@ +package nl.trivion.softwarefabric.agents + +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.dsl.builder.forwardTo +import ai.koog.agents.core.dsl.builder.strategy +import ai.koog.agents.core.dsl.extension.nodeExecuteTool +import ai.koog.agents.core.dsl.extension.nodeLLMRequest +import ai.koog.agents.core.dsl.extension.nodeLLMSendToolResult +import ai.koog.agents.core.dsl.extension.onAssistantMessage +import ai.koog.agents.core.dsl.extension.onToolCall +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.core.tools.reflect.asTools +import ai.koog.agents.features.eventHandler.feature.EventHandler +import ai.koog.agents.snapshot.feature.Persistence +import ai.koog.agents.snapshot.feature.persistence +import ai.koog.agents.snapshot.providers.InMemoryPersistenceStorageProvider +import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider +import ai.koog.prompt.dsl.Prompt +import ai.koog.prompt.executor.llms.all.simpleOllamaAIExecutor +import ai.koog.prompt.llm.LLMCapability +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.llm.LLModel +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import lombok.extern.slf4j.Slf4j +import nl.trivion.softwarefabric.service.GiteaService +import nl.trivion.softwarefabric.service.Issue +import nl.trivion.softwarefabric.tools.FileTools +import nl.trivion.softwarefabric.tools.ProjectTool +import org.example.nl.trivion.softwarefabric.tools.GitTools +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.io.File +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.path.Path + +@Service +@Slf4j +class DesignerAgent(private val giteaService: GiteaService) { + + companion object { + private val log = LoggerFactory.getLogger(DesignerAgent::class.java) + } + + // Flag to track if agent is busy + private val isAgentBusy = AtomicBoolean(false) + + val promptExecutor = simpleOllamaAIExecutor("http://localhost:11434") + + val customModel: LLModel = LLModel( + provider = LLMProvider.Ollama, + id = "gpt-oss:20b", + capabilities = listOf( + LLMCapability.Temperature, + LLMCapability.Tools + ), + contextLength = 2048 + ) + + // Create a simple strategy + val agentStrategy = strategy("Designer") { + // Define nodes for the strategy + val nodeSendInput by nodeLLMRequest() + val nodeExecuteTool by nodeExecuteTool() + val nodeSendToolResult by nodeLLMSendToolResult() + + // Define edges between nodes + // Start -> Send input + edge(nodeStart forwardTo nodeSendInput) + + // Send input -> Finish (when assistant responds with text) + edge( + (nodeSendInput forwardTo nodeFinish) + transformed { it } + onAssistantMessage { true } + ) + + // Send input -> Execute tool (when assistant makes a tool call) + edge( + (nodeSendInput forwardTo nodeExecuteTool) + onToolCall { true } + ) + + // Execute tool -> Send the tool result + edge(nodeExecuteTool forwardTo nodeSendToolResult) + + // Send the tool result -> finish (when assistant responds with text) + edge( + (nodeSendToolResult forwardTo nodeFinish) + transformed { it } + onAssistantMessage { true } + ) + + // NIEUWE EDGE: Send the tool result -> Execute another tool (when assistant makes another tool call) + edge( + (nodeSendToolResult forwardTo nodeExecuteTool) + onToolCall { true } + ) + } + + val systemPrompt = this::class.java.getResource("/prompts/designer-system-prompt.txt") + ?.readText() + ?: error("System prompt not found") + + // Configure the agent + val agentConfig = AIAgentConfig( + prompt = Prompt.build("designer") { + system(systemPrompt + ) + }, + model = customModel, + maxAgentIterations = 10 + ) + + // Add the tool to the tool registry + val toolRegistry = ToolRegistry { + tools(GitTools().asTools()) + tools(FileTools().asTools()) + tools(ProjectTool(giteaService).asTools()) + } + + fun createAgent(agentId: String): AIAgent { + val provider = JVMFilePersistenceStorageProvider(Path("/Users/erik/projecten/ai-persistent")) + return AIAgent( + promptExecutor = promptExecutor, + toolRegistry = toolRegistry, + strategy = agentStrategy, + agentConfig = agentConfig, + id = agentId, + installFeatures = { + install(EventHandler) { + onAgentCompleted { it -> + log.info("Result: ${it.result}") + isAgentBusy.set(false) + } + } + install(Persistence) { + // Use in-memory storage for snapshots + storage = provider + // Enable automatic persistence + enableAutomaticPersistence = true + } + } + ) + } + + @Scheduled(fixedRate = 5000) + fun checkIssueToDo() { + // Check if agent is already busy + if (isAgentBusy.get()) { + log.info("Agent is still busy processing previous issue, skipping this cycle") + return + } + + val issues = giteaService.getIssues().filter { it: Issue -> + it.labels.any() { it in listOf("New", "Answered")} + && it.assigned.isNullOrEmpty() + } + + if (issues.isEmpty()) { + log.info("No issues found") + return + } + + val issue = issues.first() + log.info("Processing issue ${issue.number}: ${issue.title}") + + // Set agent as busy before starting + if (!isAgentBusy.compareAndSet(false, true)) { + log.info("Agent became busy while checking, skipping") + return + } + + val taskMessage = if (issue.labels.contains("New")) + "taskNumber: ${issue.number} \nTitle: ${issue.title}\n\n${issue.body}" + else { + val comments = giteaService.getComments(issue.number) + val comment = comments.last() + "answer: ${comment.body}" + } + + GlobalScope.launch { + try { + giteaService.updateIssueLabels(issue.number, listOf("Design")) + val agent = createAgent("developer-agent-issue-tasklist-${issue.number}") + val result = agent.run(taskMessage) + log.info("Agent completed with result: $result") + giteaService.updateIssueLabels(issue.number, listOf("Ready")) + } catch (e: Exception) { + log.error("Error processing issue: ${e.message}", e) + } finally { + // Always reset the busy flag + isAgentBusy.set(false) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/trivion/softwarefabric/agents/DeveloperAgent.kt b/src/main/kotlin/nl/trivion/softwarefabric/agents/DeveloperAgent.kt index 5ef91f8..e2a00c5 100644 --- a/src/main/kotlin/nl/trivion/softwarefabric/agents/DeveloperAgent.kt +++ b/src/main/kotlin/nl/trivion/softwarefabric/agents/DeveloperAgent.kt @@ -139,7 +139,7 @@ class DeveloperAgent(private val giteaService: GiteaService) { ) } - @Scheduled(fixedRate = 5000) +// @Scheduled(fixedRate = 5000) fun checkIssueToDo() { // Check if agent is already busy if (isAgentBusy.get()) { diff --git a/src/main/kotlin/nl/trivion/softwarefabric/service/GiteaService.kt b/src/main/kotlin/nl/trivion/softwarefabric/service/GiteaService.kt index 76a97f1..7a417eb 100644 --- a/src/main/kotlin/nl/trivion/softwarefabric/service/GiteaService.kt +++ b/src/main/kotlin/nl/trivion/softwarefabric/service/GiteaService.kt @@ -39,6 +39,12 @@ data class Assignee( val fullName: String ) +data class User( + val login: String, + @SerializedName("full_name") + val fullName: String +) + data class Label( val id: Int, val name: String, @@ -53,6 +59,34 @@ data class CreateIssueRequest( val labels: List? = null ) +data class Comment( + val body: String, + val user: String +) + +data class GiteaAddCommentRequest( + val body: String +) + +data class GiteaCommentResponse( + val body: String, + val user: User +) + +data class CreateReactionRequest( + val content: String +) + +data class GiteaReactionResponse( + val content: String, + val user: User +) + +data class Reaction( + val content: String, + val user: String +) + data class UpdateLabelsRequest( val labels: List ) @@ -125,7 +159,6 @@ class GiteaService { } private fun mapToIssue(giteaIssue: GiteaIssueResponse): Issue { - log.info("Gitea issue: {}", giteaIssue.toString()) return Issue( number = giteaIssue.number, title = giteaIssue.title, @@ -137,6 +170,20 @@ class GiteaService { ) } + private fun mapToReaction(giteaReaction: GiteaReactionResponse): Reaction { + return Reaction( + content = giteaReaction.content, + user = giteaReaction.user.login + ) + } + + private fun mapToComment(giteaComment: GiteaCommentResponse): Comment { + return Comment( + body = giteaComment.body, + user = giteaComment.user.login + ) + } + private fun getIssueById(issueNumber: Int): Issue? { val url = "$baseUrl/issues/$issueNumber" val request = buildGetRequest(url) @@ -191,6 +238,22 @@ class GiteaService { } } + fun getComments(issueNumber: Int): List { + val url = "$baseUrl/issues/$issueNumber/comments" + val request = buildGetRequest(url) + + executeRequest(request).use { response -> + if (!response.isSuccessful) { + println("Unexpected code $response") + return emptyList() + } + + val json = response.body?.string() ?: return emptyList() + val giteaComments = gson.fromJson(json, Array::class.java) + return giteaComments.map { mapToComment(it) } + } + } + private fun createIssueLabelIds( title: String, body: String, @@ -258,4 +321,47 @@ class GiteaService { val labelIds = getLabelIdsByNames(labelNames) return updateIssueLabelsIds(issueNumber, labelIds) } + + fun createReaction( + issueNumber: Int, + content: String + ): Reaction? { + val createRequest = CreateReactionRequest(content) + val url = "$baseUrl/issues/$issueNumber/reactions" + val request = buildPostRequest(url, createRequest) + + executeRequest(request).use { response -> + if (!response.isSuccessful) { + println("Failed to create issue: $response") + println("Response body: ${response.body?.string()}") + return null + } + + val json = response.body?.string() ?: return null + val giteaReaction = gson.fromJson(json, GiteaReactionResponse::class.java) + return mapToReaction(giteaReaction) + } + } + + fun addComment( + issueNumber: Int, + body: String + ): Comment? { + val createRequest = GiteaAddCommentRequest(body) + val url = "$baseUrl/issues/$issueNumber/comments" + val request = buildPostRequest(url, createRequest) + + executeRequest(request).use { response -> + if (!response.isSuccessful) { + println("Failed to add comment: $response") + println("Response body: ${response.body?.string()}") + return null + } + + val json = response.body?.string() ?: return null + val giteaComment = gson.fromJson(json, GiteaCommentResponse::class.java) + return mapToComment(giteaComment) + } + } + } \ No newline at end of file diff --git a/src/main/kotlin/nl/trivion/softwarefabric/tools/FileTools.kt b/src/main/kotlin/nl/trivion/softwarefabric/tools/FileTools.kt new file mode 100644 index 0000000..4c6ca98 --- /dev/null +++ b/src/main/kotlin/nl/trivion/softwarefabric/tools/FileTools.kt @@ -0,0 +1,46 @@ +package nl.trivion.softwarefabric.tools + +import ai.koog.agents.core.tools.annotations.LLMDescription +import ai.koog.agents.core.tools.annotations.Tool +import ai.koog.agents.core.tools.reflect.ToolSet +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.Paths +import java.nio.file.StandardOpenOption + +@LLMDescription("Tools for performing basic file operations") +class FileTools : ToolSet { + + companion object { + private val log = LoggerFactory.getLogger(FileTools::class.java) + } + + val workspace = "/Users/erik/projecten/workspace" + + @Tool + @LLMDescription("Write a file to the relative path") + fun writeFile( + @LLMDescription("The relative path where the file should be created") + relativePath: String, + @LLMDescription("The name of the file to create") + filename: String, + @LLMDescription("The content to write to the file") + fileContent: String + ) { + log.info("Write '{}' to '{}'", filename, relativePath) + + // Combineer workspace, relatieve pad en bestandsnaam + val fullPath = Paths.get(workspace, relativePath, filename) + + // Maak de directory structuur aan als deze nog niet bestaat + Files.createDirectories(fullPath.parent) + + // Schrijf de inhoud naar het bestand + Files.writeString( + fullPath, + fileContent, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/trivion/softwarefabric/tools/GitTools.kt b/src/main/kotlin/nl/trivion/softwarefabric/tools/GitTools.kt index de18ee7..e8190e3 100644 --- a/src/main/kotlin/nl/trivion/softwarefabric/tools/GitTools.kt +++ b/src/main/kotlin/nl/trivion/softwarefabric/tools/GitTools.kt @@ -6,7 +6,6 @@ import ai.koog.agents.core.tools.reflect.ToolSet import org.eclipse.jgit.api.Git import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value import java.io.File //val username: String? = System.getenv("GIT_USERNAME") @@ -34,9 +33,10 @@ class GitTools : ToolSet { projectName: String): String { val localPath = workspace + File.separatorChar + projectName - log.info("localPath: {}", localPath) + log.info("Cloning repository {} to {}", remoteUrl, localPath) if (File(localPath).exists()) { + log.info("$projectName already cloned") return "\"$projectName\" already cloned" } @@ -58,9 +58,9 @@ class GitTools : ToolSet { @LLMDescription("The local path from the local git repository") projectName: String, @LLMDescription("The branch to pull") - branch: String) { + branch: String): String { - val localPath = workspace + File.pathSeparator + projectName + val localPath = workspace + File.separatorChar + projectName Git.open(File(localPath)).use { git -> git.pull() .setRemote("origin") @@ -68,5 +68,41 @@ class GitTools : ToolSet { .setCredentialsProvider(UsernamePasswordCredentialsProvider(username, password)) .call() } + return "Project $projectName successfully pulled" + } + + @Tool + @LLMDescription("Commit changed to repository") + fun commit( + @LLMDescription("The local path from the local git repository") + projectName: String, + @LLMDescription("Commit message") + message: String): String { + + val localPath = workspace + File.separatorChar + projectName + Git.open(File(localPath)).use { git -> + git.add().addFilepattern(".").call() + git.commit() + .setMessage(message) + .setCredentialsProvider(UsernamePasswordCredentialsProvider(username, password)) + .call() + } + return "Project $projectName successfully commit changes" + } + + @Tool + @LLMDescription("Push to remote repository") + fun push( + @LLMDescription("The local path from the local git repository") + projectName: String): String { + + val localPath = workspace + File.separatorChar + projectName + Git.open(File(localPath)).use { git -> + git.push() + .setRemote("origin") + .setCredentialsProvider(UsernamePasswordCredentialsProvider(username, password)) + .call() + } + return "Project $projectName successfully pushed to remote repository" } } \ No newline at end of file diff --git a/src/main/kotlin/nl/trivion/softwarefabric/tools/ProjectTool.kt b/src/main/kotlin/nl/trivion/softwarefabric/tools/ProjectTool.kt new file mode 100644 index 0000000..3e2f652 --- /dev/null +++ b/src/main/kotlin/nl/trivion/softwarefabric/tools/ProjectTool.kt @@ -0,0 +1,34 @@ +package nl.trivion.softwarefabric.tools + +import ai.koog.agents.core.agent.context.AIAgentContext +import ai.koog.agents.core.tools.annotations.LLMDescription +import ai.koog.agents.core.tools.annotations.Tool +import ai.koog.agents.core.tools.reflect.ToolSet +import ai.koog.agents.snapshot.feature.persistence +import nl.trivion.softwarefabric.service.GiteaService +import org.slf4j.LoggerFactory +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +@LLMDescription("Tools for project management and team communication. Use these tools when you need to ask questions to team members about the project.") +class ProjectTool(private val giteaService: GiteaService): ToolSet { + + companion object { + private val log = LoggerFactory.getLogger(ProjectTool::class.java) + } + + @Tool + @LLMDescription("Ask a question to a specific team member. Use this when you need clarification, information, or input from someone in the team. The question will be routed to the appropriate team member based on their role.") + fun askQuestion( + @LLMDescription("The complete question you want to ask. Be specific and provide context so the team member understands what you need. For example: 'What should be the maximum file size for uploads?' or 'Should we use dark mode as default?'") + question: String, + @LLMDescription("The task number.") + taskNumber: Int, + @LLMDescription("The role of the team member who should answer this question. Options: 'user' (for product owner/stakeholder questions about requirements), 'developer' (for technical implementation questions), 'tester' (for testing and quality assurance questions), 'designer' (for UI/UX and design questions).") + role: String): String { + log.info("role: $role question: $question") + + giteaService.addComment(taskNumber, question) + return "Question is send to $role. Do not ask any more questions until this question is answered!" + } +} \ No newline at end of file diff --git a/src/main/resources/prompts/designer-system-prompt.txt b/src/main/resources/prompts/designer-system-prompt.txt new file mode 100644 index 0000000..193fed2 --- /dev/null +++ b/src/main/resources/prompts/designer-system-prompt.txt @@ -0,0 +1,94 @@ +You are an expert software architect and designer specializing in Kotlin-based microservices that run in Docker containers. + +Your role is to create comprehensive technical designs for developers who will implement Kotlin components. Focus on the WHAT and WHY, not the HOW. + +## Available Tools + +You have access to the following tools: + +1. **GIT Tool**: Use this to clone the project repository and push changes + - Clone the repository using the project name + - After creating the design.md file, commit and push it to the repository + +2. **Question Tool**: Use this to ask clarification questions to the user when needed + +## Workflow + +1. Ask for the project name if not provided +2. Use the GIT tool to clone the repository +3. Analyze existing code and structure if available +4. Ask clarification questions if needed using the question tool +5. Create the comprehensive design +6. Save the design as design.md in the project root +7. Use the GIT tool to commit and push the design.md file to the repository + +## Input Requirements + +The user must provide the **project name**. You will use this project name to: +1. Retrieve the corresponding GIT repository +2. Analyze existing code and structure if available +3. Save the design document in the correct project location + +If the project name is not provided, ask the user for it before proceeding. + +## Your Responsibilities + +1. **Design Complete Interfaces**: Define detailed interfaces with clear method signatures, parameters, return types, and documentation +2. **Identify Services**: Determine which services are needed and describe their responsibilities +3. **Define Data Contracts**: Specify data models, DTOs, and domain objects with all properties +4. **Describe Component Interactions**: Explain how services communicate and depend on each other +5. **Document API Contracts**: Define REST endpoints, message formats, or event structures +6. **Specify External Dependencies**: Identify databases, message queues, external APIs, and other infrastructure needs +7. **Define Configuration Requirements**: List environment variables, configuration properties, and deployment parameters + +## Output Requirements + +Your final design MUST be saved as a file named **design.md** in the root directory of the project. + +The design.md file should contain all the sections described in the "Output Format" section below. + +## Asking Questions + +If anything is unclear or you need more information to create a complete design, you MUST ask the user for clarification. Use the available tool to ask questions to the user. + +Do not make assumptions about critical design decisions. It's better to ask than to design something that doesn't meet the requirements. + +Examples of when to ask questions: +- Unclear business requirements or use cases +- Missing information about external systems or APIs +- Ambiguous performance or scalability requirements +- Uncertainty about authentication/authorization needs +- Questions about data retention, privacy, or compliance requirements + +## Design Principles + +- Use clear, descriptive names for interfaces, services, and methods +- Document the purpose and responsibility of each component +- Specify input validation requirements and error handling expectations +- Define data flow between components +- Consider scalability, reliability, and maintainability +- Think about observability: logging, metrics, and health checks +- Design for Docker deployment: consider 12-factor app principles + +## What NOT to Do + +- Do NOT provide implementation details or actual code +- Do NOT decide on specific frameworks or libraries (unless critical to the design) +- Do NOT specify project structure, package names, or file organization +- Do NOT write actual Kotlin code (only interface signatures) + +## Output Format + +For each design request, provide: + +1. **Overview**: Brief description of what needs to be built +2. **Services**: List of services with their responsibilities +3. **Interfaces**: Detailed interface definitions with method signatures +4. **Data Models**: Domain objects and DTOs with all properties +5. **Service Communication**: How services interact (REST, events, etc.) +6. **External Dependencies**: Databases, message brokers, third-party APIs +7. **Configuration**: Required environment variables and settings +8. **Docker Considerations**: Ports, volumes, health checks, and container-specific requirements +9. **Non-Functional Requirements**: Performance, security, and operational concerns + +Remember: Your designs should be complete enough that a developer can implement them without making major architectural decisions, but flexible enough to allow technical creativity in the implementation. \ No newline at end of file