Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# SPDX-License-Identifier: CC0-1.0

.gradle
.env
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
Expand Down
6 changes: 5 additions & 1 deletion adl-server/src/main/kotlin/AdlServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import org.eclipse.lmos.adl.server.repositories.RolePromptRepository
import org.eclipse.lmos.adl.server.repositories.TestRunRepository
import org.eclipse.lmos.adl.server.repositories.UseCaseEmbeddingsRepository
import org.eclipse.lmos.adl.server.repositories.impl.FileSystemAdlRepository
import org.eclipse.lmos.adl.server.repositories.impl.FileSystemAgentRepository
import org.eclipse.lmos.adl.server.repositories.impl.InMemoryAdlRepository
import org.eclipse.lmos.adl.server.repositories.impl.db.PostgresAdlRepository
import com.zaxxer.hikari.HikariConfig
Expand Down Expand Up @@ -166,7 +167,10 @@ fun startServer(
}
val ownerAccessResolver = OwnerAccessResolver(organizationRepository)
val rolePromptRepository: RolePromptRepository = InMemoryRolePromptRepository()
val agentRepository: AgentRepository = InMemoryAgentRepository()
val agentRepository: AgentRepository = when {
fileRepositoryFolders.adlFolder != null -> FileSystemAgentRepository(fileRepositoryFolders.adlFolder)
else -> InMemoryAgentRepository()
}
val mcpService = McpService()
val testCaseRepository = when {
fileRepositoryFolders.testCaseFolder != null -> FileSystemTestCaseRepository(fileRepositoryFolders.testCaseFolder)
Expand Down
89 changes: 89 additions & 0 deletions adl-server/src/main/kotlin/repositories/impl/ADLAgentParser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2026 Deutsche Telekom AG and others
//
// SPDX-License-Identifier: Apache-2.0

package org.eclipse.lmos.adl.server.repositories.impl

import org.eclipse.lmos.adl.server.DEFAULT_OWNER
import org.eclipse.lmos.adl.server.models.Agent

/**
* Parses ADL agent Markdown documents into agent models.
*/
class ADLAgentParser(
private val processor: YamlFrontMatterProcessor = YamlFrontMatterProcessor(),
) {

/**
* Parses a Markdown string into an [Agent].
*/
fun parse(markdown: String): Agent = parse(markdown, fallbackId = null, fallbackOwner = DEFAULT_OWNER)

/**
* Parses a Markdown string into an [Agent] using repository-provided fallbacks when metadata is absent.
*/
fun parse(markdown: String, fallbackId: String?, fallbackOwner: String): Agent {
val frontMatter = processor.parse(markdown)
val metadata = frontMatter.metadata
val name = agentNameFrom(frontMatter.content)
val id = metadata["id"]?.toString() ?: fallbackId ?: name

require(!id.isNullOrBlank()) { "ADL agent markdown must define an id or an Agent heading." }

return Agent(
id = id,
name = name ?: id,
active = activeFrom(metadata),
description = sectionFrom(frontMatter.content, "Description"),
models = stringListFrom(metadata["models"]),
tags = stringListFrom(metadata["tags"]),
mcpServers = stringListFrom(metadata["mcpServers"]),
role = metadata["role"]?.toString(),
corePrompt = sectionFrom(frontMatter.content, "Prompt"),
owner = metadata["owner"]?.toString() ?: fallbackOwner,
)
}

private fun agentNameFrom(content: String): String? {
return content.lineSequence()
.mapNotNull { line -> AGENT_HEADING.matchEntire(line.trim())?.groupValues?.get(1)?.trim() }
.firstOrNull { it.isNotBlank() }
}

private fun sectionFrom(content: String, title: String): String? {
val lines = content.lines()
val start = lines.indexOfFirst { line -> headingTitle(line)?.equals(title, ignoreCase = true) == true }
if (start == -1) return null

val startLevel = headingLevel(lines[start]) ?: return null
val end = lines.drop(start + 1).indexOfFirst { line ->
val level = headingLevel(line)
level != null && level <= startLevel
}.let { relativeEnd -> if (relativeEnd == -1) lines.size else start + 1 + relativeEnd }

return lines.subList(start + 1, end)
.joinToString("\n")
.trim()
.takeIf { it.isNotBlank() }
}

private fun headingTitle(line: String): String? = HEADING.matchEntire(line.trim())?.groupValues?.get(2)?.trim()

private fun headingLevel(line: String): Int? = HEADING.matchEntire(line.trim())?.groupValues?.get(1)?.length

private fun activeFrom(metadata: Map<String, Any>): Boolean {
val active = metadata["active"] ?: return true
return active as? Boolean ?: active.toString().toBooleanStrictOrNull() ?: true
}

private fun stringListFrom(value: Any?): List<String>? {
return (value as? List<*>)
?.map { it.toString() }
?.takeIf { it.isNotEmpty() }
}

private companion object {
val AGENT_HEADING = Regex("^#{1,6}\\s+Agent:\\s*(.+?)\\s*$")
val HEADING = Regex("^(#{1,6})\\s+(.+?)\\s*$")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class FileSystemAdlRepository(private val folder: File) : AdlRepository {

override suspend fun get(id: String): Adl? {
val file = fileForId(id, currentOwner())
if (file.name.endsWith(AGENT_FILE_EXTENSION)) return null
if (!file.exists()) return null

return try {
Expand Down Expand Up @@ -78,7 +79,7 @@ class FileSystemAdlRepository(private val folder: File) : AdlRepository {

override suspend fun list(): List<Adl> {
val ownerFolder = folderForOwner(currentOwner())
return ownerFolder.listFiles { _, name -> name.endsWith(".md") }
return ownerFolder.listFiles { _, name -> name.endsWith(".md") && !name.endsWith(AGENT_FILE_EXTENSION) }
?.mapNotNull { file ->
val id = fileIdFrom(file, currentOwner())
get(id)
Expand Down Expand Up @@ -114,4 +115,8 @@ class FileSystemAdlRepository(private val folder: File) : AdlRepository {
private fun fileIdFrom(file: File, owner: String): String {
return if (owner == DEFAULT_OWNER) file.nameWithoutExtension else decodeId(file.nameWithoutExtension)
}

private companion object {
const val AGENT_FILE_EXTENSION = ".agent.md"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// SPDX-FileCopyrightText: 2026 Deutsche Telekom AG and others
//
// SPDX-License-Identifier: Apache-2.0

package org.eclipse.lmos.adl.server.repositories.impl

import org.eclipse.lmos.adl.server.DEFAULT_OWNER
import org.eclipse.lmos.adl.server.currentOwner
import org.eclipse.lmos.adl.server.models.Agent
import org.eclipse.lmos.adl.server.repositories.AgentRepository
import org.slf4j.LoggerFactory
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption.ATOMIC_MOVE
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
import java.util.Base64

/**
* Stores and loads ADL agent definitions from Markdown files with YAML front matter.
*/
class FileSystemAgentRepository(private val folder: File) : AgentRepository {
private val log = LoggerFactory.getLogger(FileSystemAgentRepository::class.java)
private val processor = YamlFrontMatterProcessor()
private val parser = ADLAgentParser(processor)

init {
if (!folder.exists()) {
folder.mkdirs()
}
}

override fun save(agent: Agent): Agent {
val scopedAgent = agent.copy(owner = currentOwner())
val targetFile = fileForId(scopedAgent.id, scopedAgent.owner)
targetFile.parentFile?.mkdirs()

val metadata = mutableMapOf<String, Any>("id" to scopedAgent.id)
metadata["owner"] = scopedAgent.owner
if (!scopedAgent.active) metadata["active"] = scopedAgent.active
if (!scopedAgent.tags.isNullOrEmpty()) metadata["tags"] = scopedAgent.tags
if (!scopedAgent.mcpServers.isNullOrEmpty()) metadata["mcpServers"] = scopedAgent.mcpServers
if (!scopedAgent.models.isNullOrEmpty()) metadata["models"] = scopedAgent.models
if (!scopedAgent.role.isNullOrBlank()) metadata["role"] = scopedAgent.role

val tempFile = File.createTempFile(targetFile.nameWithoutExtension, ".tmp", targetFile.parentFile)
return try {
tempFile.writeText(processor.write(YamlFrontMatter(metadata, renderContent(scopedAgent))))
try {
Files.move(tempFile.toPath(), targetFile.toPath(), REPLACE_EXISTING, ATOMIC_MOVE)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(tempFile.toPath(), targetFile.toPath(), REPLACE_EXISTING)
}
scopedAgent
} catch (exception: Exception) {
tempFile.delete()
throw exception
}
}

override fun findById(id: String): Agent? {
val file = fileForId(id, currentOwner())
if (file.exists()) {
return readAgent(file)
}

return findAll().firstOrNull { it.id == id }
}

override fun findAll(): List<Agent> {
val ownerFolder = folderForOwner(currentOwner())
return ownerFolder.listFiles { _, fileName -> fileName.endsWith(FILE_EXTENSION) }
?.sortedBy { it.name }
?.mapNotNull(::readAgent)
?: emptyList()
}

override fun delete(id: String): Boolean {
val file = fileForId(id, currentOwner())
if (file.exists()) {
return file.delete()
}

val matchingFile = folderForOwner(currentOwner())
.listFiles { _, fileName -> fileName.endsWith(FILE_EXTENSION) }
?.firstOrNull { readAgent(it)?.id == id }

return matchingFile?.delete() ?: false
}

private fun readAgent(file: File): Agent? {
return try {
parser.parse(file.readText(), fallbackId = file.name.removeSuffix(FILE_EXTENSION), fallbackOwner = currentOwner())
} catch (exception: Exception) {
log.error("Error reading ADL agent file {}", file.name, exception)
null
}
}

private fun renderContent(agent: Agent): String = buildString {
appendLine("## Agent: ${agent.name}")
appendLine()
agent.description?.let { description ->
appendLine("### Description")
appendLine(description.trimEnd())
appendLine()
}
agent.corePrompt?.let { prompt ->
appendLine("### Prompt")
append(prompt.trimEnd())
}
}

private fun fileForId(id: String, owner: String): File {
return File(folderForOwner(owner), "${encode(id)}$FILE_EXTENSION")
}

private fun folderForOwner(owner: String): File {
return if (owner == DEFAULT_OWNER) folder else File(folder, encode(owner)).apply { mkdirs() }
}

private fun encode(value: String): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(Charsets.UTF_8))

private companion object {
const val FILE_EXTENSION = ".agent.md"
}
}

123 changes: 123 additions & 0 deletions adl-server/src/test/kotlin/ADLAgentParserTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: 2026 Deutsche Telekom AG and others
//
// SPDX-License-Identifier: Apache-2.0

package org.eclipse.lmos.adl.server

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.eclipse.lmos.adl.server.repositories.impl.ADLAgentParser
import org.junit.jupiter.api.Test

class ADLAgentParserTest {

private val parser = ADLAgentParser()

@Test
fun `parse returns agent model from ADL agent markdown`() {
val markdown = """
---
id: "agent id"
tags:
- "software"
- "engineering"
- "quality"
mcpServers:
- http://mcp-server
models:
- model
---

## Agent: agent_name

### Description
Description

### Prompt
corePrompt
""".trimIndent()

val agent = parser.parse(markdown)

assertThat(agent.id).isEqualTo("agent id")
assertThat(agent.name).isEqualTo("agent_name")
assertThat(agent.description).isEqualTo("Description")
assertThat(agent.corePrompt).isEqualTo("corePrompt")
assertThat(agent.tags).containsExactly("software", "engineering", "quality")
assertThat(agent.mcpServers).containsExactly("http://mcp-server")
assertThat(agent.models).containsExactly("model")
assertThat(agent.active).isTrue()
assertThat(agent.owner).isEqualTo(DEFAULT_OWNER)
}

@Test
fun `parse supports multi-line sections and stops at next heading with same or higher level`() {
val markdown = """
---
id: reviewer
active: false
owner: team-a
role: Senior reviewer
---

# Agent: Reviewer

### Description
Reviews code.

Keeps feedback concise.

#### Details
This remains part of the description.

### Prompt
Find blocking issues first.

## Other
Ignored.
""".trimIndent()

val agent = parser.parse(markdown)

assertThat(agent.id).isEqualTo("reviewer")
assertThat(agent.name).isEqualTo("Reviewer")
assertThat(agent.active).isFalse()
assertThat(agent.owner).isEqualTo("team-a")
assertThat(agent.role).isEqualTo("Senior reviewer")
assertThat(agent.description).isEqualTo(
"""
Reviews code.

Keeps feedback concise.

#### Details
This remains part of the description.
""".trimIndent()
)
assertThat(agent.corePrompt).isEqualTo("Find blocking issues first.")
}

@Test
fun `parse uses provided fallbacks when metadata is absent`() {
val markdown = """
## Agent: Fallback Agent

### Prompt
Use defaults.
""".trimIndent()

val agent = parser.parse(markdown, fallbackId = "fallback-id", fallbackOwner = "fallback-owner")

assertThat(agent.id).isEqualTo("fallback-id")
assertThat(agent.name).isEqualTo("Fallback Agent")
assertThat(agent.owner).isEqualTo("fallback-owner")
assertThat(agent.corePrompt).isEqualTo("Use defaults.")
}

@Test
fun `parse rejects markdown without id fallback or agent heading`() {
assertThatThrownBy { parser.parse("### Prompt\nNo agent heading.") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("must define an id or an Agent heading")
}
}
Loading
Loading