fix(skills): SkillParser.serialize — закрывающий fence прилипал к последней YAML-строке

kaml encodeToString не ставит завершающий перевод строки, из-за чего
сериализованный SKILL.md выглядел так:
  ---
  name: x
  description: "y"---
и SkillParser.parse находил MissingClosingFence — каждый скил,
сохранённый через skill_save / SkillMiner, становился нечитаемым после
рестарта агента (каталог терял скил).

Поставлен явный '\n' перед закрывающим fence. Добавлены юнит-тесты
round-trip serialize->parse (обычный, пустой body, спецсимволы YAML).
This commit is contained in:
2026-09-15 07:01:23 +03:00
parent df386ef875
commit 9b37edd92e
6 changed files with 140 additions and 67 deletions
@@ -152,7 +152,11 @@ object SkillParser {
return buildString { return buildString {
append("---\n") append("---\n")
append(yamlBody) append(yamlBody)
append("---\n") // kaml encodeToString не добавляет завершающий перевод строки — без
// явного `\n` закрывающий fence прилипает к последней YAML-строке
// (`description: "..."---`) и [parse] больше не находит fence
// (MissingClosingFence).
append("\n---\n")
if (body.isNotEmpty()) { if (body.isNotEmpty()) {
append("\n") append("\n")
append(body) append(body)
@@ -181,7 +181,52 @@ class SkillParserTest {
assertTrue(ok.value.description.contains("line two")) assertTrue(ok.value.description.contains("line two"))
} }
// --- parseAuto: fenced делегируется в parse, голый YAML трактуется как объект --- // --- serialize → parse round-trip (регрессия: кривой fence после serialize) ---
@Test
fun serializeParseRoundTrip() {
val skill = SkillFile(
name = "greet:ru",
description = "Приветствие по-русски",
body = "# Greet RU\nКогда пользователь здоровается, отвечай тепло и кратко по-русски.",
)
val serialized = SkillParser.serialize(skill)
// Закрывающий fence обязан быть на отдельной строке — иначе parse
// не найдёт его (MissingClosingFence).
assertTrue(
"\n---" in serialized,
"закрывающий fence должен начинаться с новой строки, got: $serialized",
)
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parse(serialized))
assertEquals("greet:ru", ok.value.name)
assertEquals("Приветствие по-русски", ok.value.description)
assertTrue(ok.value.body.startsWith("# Greet RU"))
}
@Test
fun serializeParseRoundTripEmptyBody() {
val skill = SkillFile(name = "empty-body", description = "no body", body = "")
val serialized = SkillParser.serialize(skill)
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parse(serialized))
assertEquals("empty-body", ok.value.name)
assertEquals("", ok.value.body)
}
@Test
fun serializeParseRoundTripSpecialYamlChars() {
val skill = SkillFile(
name = "special",
description = "С кавычками \"и\" колонкой: и двоеточие.",
body = "line1\nline2",
)
val serialized = SkillParser.serialize(skill)
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parse(serialized))
assertEquals(skill.description, ok.value.description)
// parse.trimStart('\n') не режет хвостовой newline — сравниваем с trimEnd.
assertEquals("line1\nline2", ok.value.body.trimEnd('\n'))
}
// --- parseAuto ---
@Test @Test
fun parseAutoDelegatesFencedFormat() { fun parseAutoDelegatesFencedFormat() {
@@ -23,25 +23,25 @@ class SkillReadTool(
) : LiteTool { ) : LiteTool {
override fun describe(): String = buildJsonObject { override fun describe(): String = buildJsonObject {
put("type", "function") // Flat OpenAPI-спецификация (name/description/parameters) — формат, который
put("function", buildJsonObject { // принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
put("name", NAME) // OpenAI-формат {"type":"function","function":{...}}.
put( put("name", NAME)
"description", put(
"Load the full text of a skill by its name. " + "description",
"Use it when the task matches one of the skills listed in the system prompt. " + "Load the full text of a skill by its name. " +
"Always read a skill before following its instructions.", "Use it when the task matches one of the skills listed in the system prompt. " +
) "Always read a skill before following its instructions.",
put("parameters", buildJsonObject { )
put("type", "object") put("parameters", buildJsonObject {
put("properties", buildJsonObject { put("type", "object")
put("name", buildJsonObject { put("properties", buildJsonObject {
put("type", "string") put("name", buildJsonObject {
put("description", "Skill name exactly as listed in the system prompt.") put("type", "string")
}) put("description", "Skill name exactly as listed in the system prompt.")
}) })
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name"))))
}) })
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name"))))
}) })
}.toString() }.toString()
@@ -83,12 +83,35 @@ private fun objString(s: String, key: String): String? {
private fun objInt(s: String, key: String): Int? = objString(s, key)?.toIntOrNull() private fun objInt(s: String, key: String): Int? = objString(s, key)?.toIntOrNull()
/**
* Flat OpenAPI-спецификация тула (name/description/parameters) — формат,
* который напрямую принимает LiteRT-LM (litert-google). litert-openai
* оборачивает её в OpenAI-формат сам. [name] совпадает с именем NamedTool.
*/
private fun toolDescribe(name: String, description: String, parameters: kotlinx.serialization.json.JsonObject): String =
buildJsonObject {
put("name", name)
put("description", description)
put("parameters", parameters)
}.toString()
/** parameters-блок JSON-Schema: {"type":"object","properties":{...},"required":[...]}. */
private fun objectSchema(
properties: kotlinx.serialization.json.JsonObject,
required: List<String> = emptyList(),
): kotlinx.serialization.json.JsonObject = buildJsonObject {
put("type", "object")
put("properties", properties)
if (required.isNotEmpty()) put("required", buildJsonArray { required.forEach { add(it) } })
}
/** memory_save(category, content) → upsert. */ /** memory_save(category, content) → upsert. */
internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject { describeJson = toolDescribe(
put("description", DefaultMemoryTools.save.description) "memory_save",
putJsonObject("parameters") { DefaultMemoryTools.save.description,
putJsonObject("properties") { objectSchema(
properties = buildJsonObject {
putJsonObject("category") { putJsonObject("category") {
put("type", "string") put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") }) put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
@@ -98,10 +121,10 @@ internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
put("type", "string") put("type", "string")
put("description", "the fact to remember") put("description", "the fact to remember")
} }
} },
putJsonArray("required") { add("category"); add("content") } required = listOf("category", "content"),
} ),
}.toString(), ),
) { args -> ) { args ->
val category = objString(args, "category")?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() } val category = objString(args, "category")?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
?: return@SyncLiteTool """{"error":"category required"}""" ?: return@SyncLiteTool """{"error":"category required"}"""
@@ -115,10 +138,11 @@ internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
/** memory_read(query, top_k?, category?) → search. */ /** memory_read(query, top_k?, category?) → search. */
internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject { describeJson = toolDescribe(
put("description", DefaultMemoryTools.read.description) "memory_read",
putJsonObject("parameters") { DefaultMemoryTools.read.description,
putJsonObject("properties") { objectSchema(
properties = buildJsonObject {
putJsonObject("query") { putJsonObject("query") {
put("type", "string") put("type", "string")
put("description", "free-text query") put("description", "free-text query")
@@ -131,10 +155,10 @@ internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
put("type", "string") put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") }) put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
} }
} },
putJsonArray("required") { add("query") } required = listOf("query"),
} ),
}.toString(), ),
) { args -> ) { args ->
val query = objString(args, "query") ?: return@SyncLiteTool """{"error":"query required"}""" val query = objString(args, "query") ?: return@SyncLiteTool """{"error":"query required"}"""
val topK = objInt(args, "top_k") ?: 5 val topK = objInt(args, "top_k") ?: 5
@@ -146,10 +170,11 @@ internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
/** memory_list(category?, limit?) → list. */ /** memory_list(category?, limit?) → list. */
internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject { describeJson = toolDescribe(
put("description", DefaultMemoryTools.list.description) "memory_list",
putJsonObject("parameters") { DefaultMemoryTools.list.description,
putJsonObject("properties") { objectSchema(
properties = buildJsonObject {
putJsonObject("category") { putJsonObject("category") {
put("type", "string") put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") }) put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
@@ -158,9 +183,9 @@ internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
put("type", "integer") put("type", "integer")
put("description", "default 20") put("description", "default 20")
} }
} },
} ),
}.toString(), ),
) { args -> ) { args ->
val category = objString(args, "category")?.takeIf { it.isNotBlank() } val category = objString(args, "category")?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() } ?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
@@ -171,18 +196,19 @@ internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
/** memory_delete(id) → delete. */ /** memory_delete(id) → delete. */
internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject { describeJson = toolDescribe(
put("description", DefaultMemoryTools.delete.description) "memory_delete",
putJsonObject("parameters") { DefaultMemoryTools.delete.description,
putJsonObject("properties") { objectSchema(
properties = buildJsonObject {
putJsonObject("id") { putJsonObject("id") {
put("type", "string") put("type", "string")
put("description", "memory note id (mem-...)") put("description", "memory note id (mem-...)")
} }
} },
putJsonArray("required") { add("id") } required = listOf("id"),
} ),
}.toString(), ),
) { args -> ) { args ->
val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}""" val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}"""
if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}""" if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}"""
@@ -158,17 +158,15 @@ class McpRegistry(
* Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий * Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий
* между MCP-серверами (например, оба могут иметь tool `search`). * между MCP-серверами (например, оба могут иметь tool `search`).
* *
* [describe] сериализует tool в JSON-дескриптор в формате, который litert-openai * [describe] сериализует tool в JSON-дескриптор — flat OpenAPI-спецификация
* и litert-google принимают как function-calling definition: * (формат, который напрямую принимает LiteRT-LM; litert-openai сам оборачивает
* её в OpenAI-формат):
* *
* ```json * ```json
* { * {
* "type": "function", * "name": "<server>__<tool>",
* "function": { * "description": "...",
* "name": "<server>__<tool>", * "parameters": { "type": "object", "properties": {...}, "required": [...] }
* "description": "...",
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
* }
* } * }
* ``` * ```
* *
@@ -187,12 +185,11 @@ internal class McpLiteToolAdapter(
override fun describe(): String = override fun describe(): String =
buildJsonObject { buildJsonObject {
put("type", "function") // Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
put("function", buildJsonObject { // litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName) put("name", fullName)
put("description", tool.description ?: "") put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema()) put("parameters", tool.inputSchema.toJsonSchema())
})
}.toString() }.toString()
override fun invoke(arguments: String): String { override fun invoke(arguments: String): String {
@@ -17,12 +17,13 @@ class SkillReadToolTest {
private val tool = SkillReadTool(catalog) private val tool = SkillReadTool(catalog)
@Test @Test
fun describeIsOpenAiFunctionSchema() { fun describeIsFlatOpenApiSchema() {
val json = tool.describe() val json = tool.describe()
assertTrue("\"type\":\"function\"" in json || "\"type\": \"function\"" in json) // Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
assertTrue(SkillReadTool.NAME in json)
assertTrue("\"name\"" in json) assertTrue("\"name\"" in json)
assertTrue(SkillReadTool.NAME in json)
assertTrue("parameters" in json) assertTrue("parameters" in json)
assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" in json))
} }
@Test @Test