From 9b37edd92e87960d13595ca61262fa940b964627 Mon Sep 17 00:00:00 2001 From: subochev Date: Tue, 15 Sep 2026 07:01:23 +0300 Subject: [PATCH] =?UTF-8?q?fix(skills):=20SkillParser.serialize=20?= =?UTF-8?q?=E2=80=94=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20fence=20=D0=BF=D1=80=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D0=BF=D0=B0=D0=BB=20=D0=BA=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B4=D0=BD=D0=B5=D0=B9=20YAML-=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kaml encodeToString не ставит завершающий перевод строки, из-за чего сериализованный SKILL.md выглядел так: --- name: x description: "y"--- и SkillParser.parse находил MissingClosingFence — каждый скил, сохранённый через skill_save / SkillMiner, становился нечитаемым после рестарта агента (каталог терял скил). Поставлен явный '\n' перед закрывающим fence. Добавлены юнит-тесты round-trip serialize->parse (обычный, пустой body, спецсимволы YAML). --- .../pw/binom/agentik/skills/SkillParser.kt | 6 +- .../binom/agentik/skills/SkillParserTest.kt | 47 +++++++++- .../agentik/standalone/agent/SkillReadTool.kt | 34 +++---- .../standalone/agent/memory/MemoryTools.kt | 88 ++++++++++++------- .../agentik/standalone/mcp/McpRegistry.kt | 25 +++--- .../standalone/agent/SkillReadToolTest.kt | 7 +- 6 files changed, 140 insertions(+), 67 deletions(-) diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt index 4e004a6..db19062 100644 --- a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt @@ -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) diff --git a/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt index eceb325..a2195c6 100644 --- a/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt +++ b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt @@ -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(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(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(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() { diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt index 9cf202f..553c586 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt @@ -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() diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt index 37b147d..ecd71b9 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt @@ -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 = 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"}""" diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpRegistry.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpRegistry.kt index aecbaad..16e3ea2 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpRegistry.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpRegistry.kt @@ -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": "__", - * "description": "...", - * "parameters": { "type": "object", "properties": {...}, "required": [...] } - * } + * "name": "__", + * "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 { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt index 2ff3a0a..8e66da7 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt @@ -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