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:
@@ -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()
|
||||
|
||||
|
||||
+57
-31
@@ -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 {
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user