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 {
|
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,8 +23,9 @@ 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 сам оборачивает её в
|
||||||
|
// OpenAI-формат {"type":"function","function":{...}}.
|
||||||
put("name", NAME)
|
put("name", NAME)
|
||||||
put(
|
put(
|
||||||
"description",
|
"description",
|
||||||
@@ -42,7 +43,6 @@ class SkillReadTool(
|
|||||||
})
|
})
|
||||||
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name"))))
|
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name"))))
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|
||||||
override fun invoke(arguments: String): String {
|
override fun invoke(arguments: String): String {
|
||||||
|
|||||||
+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()
|
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,18 +158,16 @@ 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",
|
|
||||||
* "function": {
|
|
||||||
* "name": "<server>__<tool>",
|
* "name": "<server>__<tool>",
|
||||||
* "description": "...",
|
* "description": "...",
|
||||||
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
|
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
|
||||||
* }
|
* }
|
||||||
* }
|
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* [invoke] вызывает [Client.callTool] по оригинальному (непрефиксованному) имени тула
|
* [invoke] вызывает [Client.callTool] по оригинальному (непрефиксованному) имени тула
|
||||||
@@ -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 {
|
||||||
|
|||||||
+4
-3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user