Skip to content

Commit 4b2d8d4

Browse files
committed
Add FileSystemAgentRepository and ADLAgentParser for agent management
1 parent d261896 commit 4b2d8d4

8 files changed

Lines changed: 551 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: CC0-1.0
44

55
.gradle
6+
.env
67
build/
78
!gradle/wrapper/gradle-wrapper.jar
89
!**/src/main/**/build/

adl-server/src/main/kotlin/AdlServer.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import org.eclipse.lmos.adl.server.repositories.RolePromptRepository
6666
import org.eclipse.lmos.adl.server.repositories.TestRunRepository
6767
import org.eclipse.lmos.adl.server.repositories.UseCaseEmbeddingsRepository
6868
import org.eclipse.lmos.adl.server.repositories.impl.FileSystemAdlRepository
69+
import org.eclipse.lmos.adl.server.repositories.impl.FileSystemAgentRepository
6970
import org.eclipse.lmos.adl.server.repositories.impl.InMemoryAdlRepository
7071
import org.eclipse.lmos.adl.server.repositories.impl.db.PostgresAdlRepository
7172
import com.zaxxer.hikari.HikariConfig
@@ -166,7 +167,10 @@ fun startServer(
166167
}
167168
val ownerAccessResolver = OwnerAccessResolver(organizationRepository)
168169
val rolePromptRepository: RolePromptRepository = InMemoryRolePromptRepository()
169-
val agentRepository: AgentRepository = InMemoryAgentRepository()
170+
val agentRepository: AgentRepository = when {
171+
fileRepositoryFolders.adlFolder != null -> FileSystemAgentRepository(fileRepositoryFolders.adlFolder)
172+
else -> InMemoryAgentRepository()
173+
}
170174
val mcpService = McpService()
171175
val testCaseRepository = when {
172176
fileRepositoryFolders.testCaseFolder != null -> FileSystemTestCaseRepository(fileRepositoryFolders.testCaseFolder)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-FileCopyrightText: 2026 Deutsche Telekom AG and others
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package org.eclipse.lmos.adl.server.repositories.impl
6+
7+
import org.eclipse.lmos.adl.server.DEFAULT_OWNER
8+
import org.eclipse.lmos.adl.server.models.Agent
9+
10+
/**
11+
* Parses ADL agent Markdown documents into agent models.
12+
*/
13+
class ADLAgentParser(
14+
private val processor: YamlFrontMatterProcessor = YamlFrontMatterProcessor(),
15+
) {
16+
17+
/**
18+
* Parses a Markdown string into an [Agent].
19+
*/
20+
fun parse(markdown: String): Agent = parse(markdown, fallbackId = null, fallbackOwner = DEFAULT_OWNER)
21+
22+
/**
23+
* Parses a Markdown string into an [Agent] using repository-provided fallbacks when metadata is absent.
24+
*/
25+
fun parse(markdown: String, fallbackId: String?, fallbackOwner: String): Agent {
26+
val frontMatter = processor.parse(markdown)
27+
val metadata = frontMatter.metadata
28+
val name = agentNameFrom(frontMatter.content)
29+
val id = metadata["id"]?.toString() ?: fallbackId ?: name
30+
31+
require(!id.isNullOrBlank()) { "ADL agent markdown must define an id or an Agent heading." }
32+
33+
return Agent(
34+
id = id,
35+
name = name ?: id,
36+
active = activeFrom(metadata),
37+
description = sectionFrom(frontMatter.content, "Description"),
38+
models = stringListFrom(metadata["models"]),
39+
tags = stringListFrom(metadata["tags"]),
40+
mcpServers = stringListFrom(metadata["mcpServers"]),
41+
role = metadata["role"]?.toString(),
42+
corePrompt = sectionFrom(frontMatter.content, "Prompt"),
43+
owner = metadata["owner"]?.toString() ?: fallbackOwner,
44+
)
45+
}
46+
47+
private fun agentNameFrom(content: String): String? {
48+
return content.lineSequence()
49+
.mapNotNull { line -> AGENT_HEADING.matchEntire(line.trim())?.groupValues?.get(1)?.trim() }
50+
.firstOrNull { it.isNotBlank() }
51+
}
52+
53+
private fun sectionFrom(content: String, title: String): String? {
54+
val lines = content.lines()
55+
val start = lines.indexOfFirst { line -> headingTitle(line)?.equals(title, ignoreCase = true) == true }
56+
if (start == -1) return null
57+
58+
val startLevel = headingLevel(lines[start]) ?: return null
59+
val end = lines.drop(start + 1).indexOfFirst { line ->
60+
val level = headingLevel(line)
61+
level != null && level <= startLevel
62+
}.let { relativeEnd -> if (relativeEnd == -1) lines.size else start + 1 + relativeEnd }
63+
64+
return lines.subList(start + 1, end)
65+
.joinToString("\n")
66+
.trim()
67+
.takeIf { it.isNotBlank() }
68+
}
69+
70+
private fun headingTitle(line: String): String? = HEADING.matchEntire(line.trim())?.groupValues?.get(2)?.trim()
71+
72+
private fun headingLevel(line: String): Int? = HEADING.matchEntire(line.trim())?.groupValues?.get(1)?.length
73+
74+
private fun activeFrom(metadata: Map<String, Any>): Boolean {
75+
val active = metadata["active"] ?: return true
76+
return active as? Boolean ?: active.toString().toBooleanStrictOrNull() ?: true
77+
}
78+
79+
private fun stringListFrom(value: Any?): List<String>? {
80+
return (value as? List<*>)
81+
?.map { it.toString() }
82+
?.takeIf { it.isNotEmpty() }
83+
}
84+
85+
private companion object {
86+
val AGENT_HEADING = Regex("^#{1,6}\\s+Agent:\\s*(.+?)\\s*$")
87+
val HEADING = Regex("^(#{1,6})\\s+(.+?)\\s*$")
88+
}
89+
}

adl-server/src/main/kotlin/repositories/impl/FileSystemAdlRepository.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class FileSystemAdlRepository(private val folder: File) : AdlRepository {
4545

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

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

7980
override suspend fun list(): List<Adl> {
8081
val ownerFolder = folderForOwner(currentOwner())
81-
return ownerFolder.listFiles { _, name -> name.endsWith(".md") }
82+
return ownerFolder.listFiles { _, name -> name.endsWith(".md") && !name.endsWith(AGENT_FILE_EXTENSION) }
8283
?.mapNotNull { file ->
8384
val id = fileIdFrom(file, currentOwner())
8485
get(id)
@@ -114,4 +115,8 @@ class FileSystemAdlRepository(private val folder: File) : AdlRepository {
114115
private fun fileIdFrom(file: File, owner: String): String {
115116
return if (owner == DEFAULT_OWNER) file.nameWithoutExtension else decodeId(file.nameWithoutExtension)
116117
}
118+
119+
private companion object {
120+
const val AGENT_FILE_EXTENSION = ".agent.md"
121+
}
117122
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// SPDX-FileCopyrightText: 2026 Deutsche Telekom AG and others
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package org.eclipse.lmos.adl.server.repositories.impl
6+
7+
import org.eclipse.lmos.adl.server.DEFAULT_OWNER
8+
import org.eclipse.lmos.adl.server.currentOwner
9+
import org.eclipse.lmos.adl.server.models.Agent
10+
import org.eclipse.lmos.adl.server.repositories.AgentRepository
11+
import org.slf4j.LoggerFactory
12+
import java.io.File
13+
import java.nio.file.AtomicMoveNotSupportedException
14+
import java.nio.file.Files
15+
import java.nio.file.StandardCopyOption.ATOMIC_MOVE
16+
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
17+
import java.util.Base64
18+
19+
/**
20+
* Stores and loads ADL agent definitions from Markdown files with YAML front matter.
21+
*/
22+
class FileSystemAgentRepository(private val folder: File) : AgentRepository {
23+
private val log = LoggerFactory.getLogger(FileSystemAgentRepository::class.java)
24+
private val processor = YamlFrontMatterProcessor()
25+
private val parser = ADLAgentParser(processor)
26+
27+
init {
28+
if (!folder.exists()) {
29+
folder.mkdirs()
30+
}
31+
}
32+
33+
override fun save(agent: Agent): Agent {
34+
val scopedAgent = agent.copy(owner = currentOwner())
35+
val targetFile = fileForId(scopedAgent.id, scopedAgent.owner)
36+
targetFile.parentFile?.mkdirs()
37+
38+
val metadata = mutableMapOf<String, Any>("id" to scopedAgent.id)
39+
metadata["owner"] = scopedAgent.owner
40+
if (!scopedAgent.active) metadata["active"] = scopedAgent.active
41+
if (!scopedAgent.tags.isNullOrEmpty()) metadata["tags"] = scopedAgent.tags
42+
if (!scopedAgent.mcpServers.isNullOrEmpty()) metadata["mcpServers"] = scopedAgent.mcpServers
43+
if (!scopedAgent.models.isNullOrEmpty()) metadata["models"] = scopedAgent.models
44+
if (!scopedAgent.role.isNullOrBlank()) metadata["role"] = scopedAgent.role
45+
46+
val tempFile = File.createTempFile(targetFile.nameWithoutExtension, ".tmp", targetFile.parentFile)
47+
return try {
48+
tempFile.writeText(processor.write(YamlFrontMatter(metadata, renderContent(scopedAgent))))
49+
try {
50+
Files.move(tempFile.toPath(), targetFile.toPath(), REPLACE_EXISTING, ATOMIC_MOVE)
51+
} catch (_: AtomicMoveNotSupportedException) {
52+
Files.move(tempFile.toPath(), targetFile.toPath(), REPLACE_EXISTING)
53+
}
54+
scopedAgent
55+
} catch (exception: Exception) {
56+
tempFile.delete()
57+
throw exception
58+
}
59+
}
60+
61+
override fun findById(id: String): Agent? {
62+
val file = fileForId(id, currentOwner())
63+
if (file.exists()) {
64+
return readAgent(file)
65+
}
66+
67+
return findAll().firstOrNull { it.id == id }
68+
}
69+
70+
override fun findAll(): List<Agent> {
71+
val ownerFolder = folderForOwner(currentOwner())
72+
return ownerFolder.listFiles { _, fileName -> fileName.endsWith(FILE_EXTENSION) }
73+
?.sortedBy { it.name }
74+
?.mapNotNull(::readAgent)
75+
?: emptyList()
76+
}
77+
78+
override fun delete(id: String): Boolean {
79+
val file = fileForId(id, currentOwner())
80+
if (file.exists()) {
81+
return file.delete()
82+
}
83+
84+
val matchingFile = folderForOwner(currentOwner())
85+
.listFiles { _, fileName -> fileName.endsWith(FILE_EXTENSION) }
86+
?.firstOrNull { readAgent(it)?.id == id }
87+
88+
return matchingFile?.delete() ?: false
89+
}
90+
91+
private fun readAgent(file: File): Agent? {
92+
return try {
93+
parser.parse(file.readText(), fallbackId = file.name.removeSuffix(FILE_EXTENSION), fallbackOwner = currentOwner())
94+
} catch (exception: Exception) {
95+
log.error("Error reading ADL agent file {}", file.name, exception)
96+
null
97+
}
98+
}
99+
100+
private fun renderContent(agent: Agent): String = buildString {
101+
appendLine("## Agent: ${agent.name}")
102+
appendLine()
103+
agent.description?.let { description ->
104+
appendLine("### Description")
105+
appendLine(description.trimEnd())
106+
appendLine()
107+
}
108+
agent.corePrompt?.let { prompt ->
109+
appendLine("### Prompt")
110+
append(prompt.trimEnd())
111+
}
112+
}
113+
114+
private fun fileForId(id: String, owner: String): File {
115+
return File(folderForOwner(owner), "${encode(id)}$FILE_EXTENSION")
116+
}
117+
118+
private fun folderForOwner(owner: String): File {
119+
return if (owner == DEFAULT_OWNER) folder else File(folder, encode(owner)).apply { mkdirs() }
120+
}
121+
122+
private fun encode(value: String): String =
123+
Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(Charsets.UTF_8))
124+
125+
private companion object {
126+
const val FILE_EXTENSION = ".agent.md"
127+
}
128+
}
129+
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SPDX-FileCopyrightText: 2026 Deutsche Telekom AG and others
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package org.eclipse.lmos.adl.server
6+
7+
import org.assertj.core.api.Assertions.assertThat
8+
import org.assertj.core.api.Assertions.assertThatThrownBy
9+
import org.eclipse.lmos.adl.server.repositories.impl.ADLAgentParser
10+
import org.junit.jupiter.api.Test
11+
12+
class ADLAgentParserTest {
13+
14+
private val parser = ADLAgentParser()
15+
16+
@Test
17+
fun `parse returns agent model from ADL agent markdown`() {
18+
val markdown = """
19+
---
20+
id: "agent id"
21+
tags:
22+
- "software"
23+
- "engineering"
24+
- "quality"
25+
mcpServers:
26+
- http://mcp-server
27+
models:
28+
- model
29+
---
30+
31+
## Agent: agent_name
32+
33+
### Description
34+
Description
35+
36+
### Prompt
37+
corePrompt
38+
""".trimIndent()
39+
40+
val agent = parser.parse(markdown)
41+
42+
assertThat(agent.id).isEqualTo("agent id")
43+
assertThat(agent.name).isEqualTo("agent_name")
44+
assertThat(agent.description).isEqualTo("Description")
45+
assertThat(agent.corePrompt).isEqualTo("corePrompt")
46+
assertThat(agent.tags).containsExactly("software", "engineering", "quality")
47+
assertThat(agent.mcpServers).containsExactly("http://mcp-server")
48+
assertThat(agent.models).containsExactly("model")
49+
assertThat(agent.active).isTrue()
50+
assertThat(agent.owner).isEqualTo(DEFAULT_OWNER)
51+
}
52+
53+
@Test
54+
fun `parse supports multi-line sections and stops at next heading with same or higher level`() {
55+
val markdown = """
56+
---
57+
id: reviewer
58+
active: false
59+
owner: team-a
60+
role: Senior reviewer
61+
---
62+
63+
# Agent: Reviewer
64+
65+
### Description
66+
Reviews code.
67+
68+
Keeps feedback concise.
69+
70+
#### Details
71+
This remains part of the description.
72+
73+
### Prompt
74+
Find blocking issues first.
75+
76+
## Other
77+
Ignored.
78+
""".trimIndent()
79+
80+
val agent = parser.parse(markdown)
81+
82+
assertThat(agent.id).isEqualTo("reviewer")
83+
assertThat(agent.name).isEqualTo("Reviewer")
84+
assertThat(agent.active).isFalse()
85+
assertThat(agent.owner).isEqualTo("team-a")
86+
assertThat(agent.role).isEqualTo("Senior reviewer")
87+
assertThat(agent.description).isEqualTo(
88+
"""
89+
Reviews code.
90+
91+
Keeps feedback concise.
92+
93+
#### Details
94+
This remains part of the description.
95+
""".trimIndent()
96+
)
97+
assertThat(agent.corePrompt).isEqualTo("Find blocking issues first.")
98+
}
99+
100+
@Test
101+
fun `parse uses provided fallbacks when metadata is absent`() {
102+
val markdown = """
103+
## Agent: Fallback Agent
104+
105+
### Prompt
106+
Use defaults.
107+
""".trimIndent()
108+
109+
val agent = parser.parse(markdown, fallbackId = "fallback-id", fallbackOwner = "fallback-owner")
110+
111+
assertThat(agent.id).isEqualTo("fallback-id")
112+
assertThat(agent.name).isEqualTo("Fallback Agent")
113+
assertThat(agent.owner).isEqualTo("fallback-owner")
114+
assertThat(agent.corePrompt).isEqualTo("Use defaults.")
115+
}
116+
117+
@Test
118+
fun `parse rejects markdown without id fallback or agent heading`() {
119+
assertThatThrownBy { parser.parse("### Prompt\nNo agent heading.") }
120+
.isInstanceOf(IllegalArgumentException::class.java)
121+
.hasMessageContaining("must define an id or an Agent heading")
122+
}
123+
}

0 commit comments

Comments
 (0)