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 {
append("---\n")
append(yamlBody)
append("---\n")
// kaml encodeToString не добавляет завершающий перевод строки — без
// явного `\n` закрывающий fence прилипает к последней YAML-строке
// (`description: "..."---`) и [parse] больше не находит fence
// (MissingClosingFence).
append("\n---\n")
if (body.isNotEmpty()) {
append("\n")
append(body)
@@ -181,7 +181,52 @@ class SkillParserTest {
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
fun parseAutoDelegatesFencedFormat() {
@@ -23,25 +23,25 @@ class SkillReadTool(
) : LiteTool {
override fun describe(): String = buildJsonObject {
put("type", "function")
put("function", buildJsonObject {
put("name", NAME)
put(
"description",
"Load the full text of a skill by its name. " +
"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("properties", buildJsonObject {
put("name", buildJsonObject {
put("type", "string")
put("description", "Skill name exactly as listed in the system prompt.")
})
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
// OpenAI-формат {"type":"function","function":{...}}.
put("name", NAME)
put(
"description",
"Load the full text of a skill by its name. " +
"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("properties", buildJsonObject {
put("name", buildJsonObject {
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()
@@ -83,12 +83,35 @@ private fun objString(s: String, key: String): String? {
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. */
internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.save.description)
putJsonObject("parameters") {
putJsonObject("properties") {
describeJson = toolDescribe(
"memory_save",
DefaultMemoryTools.save.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
@@ -98,10 +121,10 @@ internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
put("type", "string")
put("description", "the fact to remember")
}
}
putJsonArray("required") { add("category"); add("content") }
}
}.toString(),
},
required = listOf("category", "content"),
),
),
) { args ->
val category = objString(args, "category")?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
?: return@SyncLiteTool """{"error":"category required"}"""
@@ -115,10 +138,11 @@ internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
/** memory_read(query, top_k?, category?) → search. */
internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.read.description)
putJsonObject("parameters") {
putJsonObject("properties") {
describeJson = toolDescribe(
"memory_read",
DefaultMemoryTools.read.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("query") {
put("type", "string")
put("description", "free-text query")
@@ -131,10 +155,10 @@ internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
}
}
putJsonArray("required") { add("query") }
}
}.toString(),
},
required = listOf("query"),
),
),
) { args ->
val query = objString(args, "query") ?: return@SyncLiteTool """{"error":"query required"}"""
val topK = objInt(args, "top_k") ?: 5
@@ -146,10 +170,11 @@ internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
/** memory_list(category?, limit?) → list. */
internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.list.description)
putJsonObject("parameters") {
putJsonObject("properties") {
describeJson = toolDescribe(
"memory_list",
DefaultMemoryTools.list.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
@@ -158,9 +183,9 @@ internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
put("type", "integer")
put("description", "default 20")
}
}
}
}.toString(),
},
),
),
) { args ->
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
@@ -171,18 +196,19 @@ internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
/** memory_delete(id) → delete. */
internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.delete.description)
putJsonObject("parameters") {
putJsonObject("properties") {
describeJson = toolDescribe(
"memory_delete",
DefaultMemoryTools.delete.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("id") {
put("type", "string")
put("description", "memory note id (mem-...)")
}
}
putJsonArray("required") { add("id") }
}
}.toString(),
},
required = listOf("id"),
),
),
) { args ->
val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}"""
if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}"""
@@ -158,17 +158,15 @@ class McpRegistry(
* Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий
* между MCP-серверами (например, оба могут иметь tool `search`).
*
* [describe] сериализует tool в JSON-дескриптор в формате, который litert-openai
* и litert-google принимают как function-calling definition:
* [describe] сериализует tool в JSON-дескриптор — flat OpenAPI-спецификация
* (формат, который напрямую принимает LiteRT-LM; litert-openai сам оборачивает
* её в OpenAI-формат):
*
* ```json
* {
* "type": "function",
* "function": {
* "name": "<server>__<tool>",
* "description": "...",
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
* }
* "name": "<server>__<tool>",
* "description": "...",
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
* }
* ```
*
@@ -187,12 +185,11 @@ internal class McpLiteToolAdapter(
override fun describe(): String =
buildJsonObject {
put("type", "function")
put("function", buildJsonObject {
put("name", fullName)
put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema())
})
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName)
put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema())
}.toString()
override fun invoke(arguments: String): String {
@@ -17,12 +17,13 @@ class SkillReadToolTest {
private val tool = SkillReadTool(catalog)
@Test
fun describeIsOpenAiFunctionSchema() {
fun describeIsFlatOpenApiSchema() {
val json = tool.describe()
assertTrue("\"type\":\"function\"" in json || "\"type\": \"function\"" in json)
assertTrue(SkillReadTool.NAME in json)
// Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
assertTrue("\"name\"" in json)
assertTrue(SkillReadTool.NAME in json)
assertTrue("parameters" in json)
assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" in json))
}
@Test