From 9afa877e390bc8456540a7dd3db24b30aa9cc500 Mon Sep 17 00:00:00 2001 From: subochev Date: Tue, 15 Sep 2026 05:20:41 +0300 Subject: [PATCH] =?UTF-8?q?tools:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20skill=5Fsave/skill=5Fdelete=20=D0=BA?= =?UTF-8?q?=20ChatAgent=20(Hermes=20Phase=203=20ready)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тулзы были написаны ранее (SkillSaveTool/SkillDeleteTool, SkillToolsFactory), но не были подключены к ChatAgent. Этот коммит закрывает пробел: * ChatAgent: новый параметр skillStore: SkillStore? = null. Когда задан — в allTools добавляются SkillToolsFactory.create(skillStore) → агенту доступны skill_save и skill_delete (помимо read_skill который всегда есть при непустом каталоге). * Main.kt: если config.skillsDir задан — создаём DiskSkillStore(File(dir)) и скармливаем агенту. Каталог используется и для чтения (SkillCatalog), и для записи (DiskSkillStore.upsert/remove) — одни и те же файлы, никаких рассинхронов между read_skill и skill_save. * SkillLoader.loadDirectory больше не нужен в Main — DiskSkillStore сам подгружает каталог в init. Удалён старый импорт. * Тесты SkillToolsTest (5): SkillSaveTool persists file and surfaces in catalog; rejects blank name; SkillDeleteTool archives (rename to .archived); errors on missing skill; colon-named skills map to nested dirs (backend:spring:db-base → backend/spring/db-base/SKILL.md). * README: раздел "Навыки" расширен описанием трёх тулов (read_skill / skill_save / skill_delete). Smoke: standalone запускается с пустым AGENTIK_SKILLS_DIR, видит "skills: 0 loaded from /tmp/skills-smoke" (DiskSkillStore создаёт каталог при отсутствии). Агент при наличии skillStore имеет в своём распоряжении все три тула для self-improvement'а. Теперь Phase 3 (skill self-improvement) реально работает end-to-end: агент может дёрнуть skill_save когда понимает что задача повторяется, потом в следующих диалогах использовать новый скил через read_skill. Tests: 160 standalone JVM (+5), 241 всего JVM, 73 native, all green. --- standalone/README.md | 19 ++++ .../pw/binom/agentik/standalone/Main.kt | 16 +-- .../agentik/standalone/agent/ChatAgent.kt | 13 ++- .../standalone/agent/SkillToolsTest.kt | 98 +++++++++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt diff --git a/standalone/README.md b/standalone/README.md index 850f842..c7d0770 100644 --- a/standalone/README.md +++ b/standalone/README.md @@ -318,6 +318,25 @@ AGENTIK_SOUL=/etc/agentik/SOUL.md AGENTIK_SKILLS_DIR=/etc/agentik/skills ``` +Когда `AGENTIK_SKILLS_DIR` задан, агенту доступны три тула для работы со +скилами (Hermes-style self-improvement): + +- **`read_skill(name)`** — загружает полный markdown скила по имени из + каталога (нужно для деталей, т.к. в system prompt обычно только краткие + описания). +- **`skill_save(name, description, body)`** — создаёт или обновляет скил. + Имя может содержать `:` (opencode-style: `backend:spring:db-base` + → `backend/spring/db-base/SKILL.md`). +- **`skill_delete(name)`** — архивирует скил (переименовывает файл в + `.archived`, оставляя возможность восстановить). + +`skill_save`/`skill_delete` не требуют рестарта агента — изменения видны +на ближайшем вызове `read_skill` (включая в этом же диалоге). + +```bash +AGENTIK_SKILLS_DIR=/etc/agentik/skills +``` + ## MCP-инструменты `AGENTIK_MCP_CONFIG` — путь к JSON-файлу со списком MCP-серверов diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt index bf49806..19f7b0a 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -18,7 +18,6 @@ import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider import pw.binom.agentik.server.agentikAgent import pw.binom.agentik.skills.SkillCatalog -import pw.binom.agentik.skills.SkillLoader import pw.binom.agentik.standalone.agent.ChatAgent import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor import pw.binom.agentik.standalone.agent.LlmReflector @@ -58,11 +57,15 @@ fun main() { val llm = config.llm.createLlm() val stores = SqliteStores.open(dbPath = config.dbPath) val mcpRegistry = McpRegistry.fromConfig(config.mcp) - val skills = config.skillsDir?.let { dir -> - val result = SkillLoader.loadDirectory(File(dir)) - result.errors.forEach { log.warn { "skill '${it.path}': ${it.message}" } } - result.catalog - } ?: SkillCatalog.EMPTY + + // Хранилище скилов: если skillsDir задан, читаем каталог + создаём + // DiskSkillStore для self-improvement (`skill_save`/`skill_delete`). + // Один и тот же файл-каталог используется и для чтения (read_skill), + // и для записи — никаких рассинхронов. + val skillStore: pw.binom.agentik.skills.SkillStore? = config.skillsDir?.let { dir -> + pw.binom.agentik.skills.DiskSkillStore(File(dir)) + } + val skills = skillStore?.catalog ?: SkillCatalog.EMPTY // SOUL.md — файл персоны. Если задан — читается как plain text/markdown, // вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0 @@ -172,6 +175,7 @@ fun main() { llmConfig = config.llm, tools = mcpRegistry.namedTools, skills = skills, + skillStore = skillStore, memoryStore = memorySystem?.store, memoryPrefetcher = memorySystem?.prefetcher, memoryReviewer = memoryReviewer, diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index afcaea4..3071431 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -50,6 +50,13 @@ class ChatAgent( private val llmConfig: LlmConfig, private val tools: List = emptyList(), private val skills: SkillCatalog = SkillCatalog.EMPTY, + /** + * Хранилище скилов для self-improvement (запись/архивирование). Если + * задано — агенту становятся доступны тулы `skill_save` и `skill_delete`. + * Скилы на чтение (`read_skill`) работают по [skills] каталогу, который + * обычно собирается из того же [skillStore]. + */ + private val skillStore: pw.binom.agentik.skills.SkillStore? = null, private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null, private val memoryPrefetcher: MemoryPrefetcher? = null, private val memoryReviewer: MemoryReviewer? = null, @@ -101,13 +108,15 @@ class ChatAgent( ) /** - * Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные - * (`read_skill`, если есть скилы; `memory_*`, если подключена память). + * Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные: + * `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть + * [skillStore]), `memory_*` (если подключена память). * MCP-тулы префиксованы `server__`, так что коллизий нет. */ private val allTools: List = buildList { addAll(tools) if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills))) + if (skillStore != null) addAll(SkillToolsFactory.create(skillStore)) if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore)) } diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt new file mode 100644 index 0000000..d7f21d4 --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt @@ -0,0 +1,98 @@ +package pw.binom.agentik.standalone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import java.io.File +import kotlin.uuid.Uuid +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.skills.DiskSkillStore +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillFile +import pw.binom.agentik.skills.SkillStore + +class SkillToolsTest { + + @Test + fun `SkillSaveTool persists and surfaces in catalog`() { + val dir = tempSkillDir() + val store: SkillStore = DiskSkillStore(dir) + val tool = SkillSaveTool(store) + + val arguments = """{"name":"my-skill","description":"Test skill","body":"# Hello"}""" + val result = tool.invoke(arguments) + assertTrue(result.contains("\"ok\":true"), "expected success, got: $result") + assertTrue(result.contains("my-skill")) + + runBlocking { + val reloaded = SkillCatalog(store.catalog.skills) + assertEquals(1, reloaded.skills.size) + val s = reloaded.skills.first() + assertEquals("my-skill", s.name) + assertEquals("Test skill", s.description) + assertEquals("# Hello", s.body) + } + + // Должен появиться файл на диске + assertTrue(File(dir, "my-skill/SKILL.md").exists()) + } + + @Test + fun `SkillSaveTool rejects blank name`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + val tool = SkillSaveTool(store) + val arguments = """{"name":"","description":"x","body":"y"}""" + // Ожидаем ошибку + try { + tool.invoke(arguments) + error("should have thrown") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("skill name", ignoreCase = true)) + } + } + + @Test + fun `SkillDeleteTool archives the skill`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + store.upsert(SkillFile(name = "to-delete", description = "x", body = "y")) + + val tool = SkillDeleteTool(store) + val result = tool.invoke("""{"name":"to-delete"}""") + assertTrue(result.contains("\"ok\":true")) + assertTrue(result.contains("to-delete")) + + // Скил исчез из каталога + assertTrue(store.catalog.skills.none { it.name == "to-delete" }) + // Файл переименован в .archived + assertTrue(File(dir, "to-delete/SKILL.md.archived").exists()) + } + + @Test + fun `SkillDeleteTool returns error for missing skill`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + val tool = SkillDeleteTool(store) + try { + tool.invoke("""{"name":"does-not-exist"}""") + error("should have thrown") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("not found", ignoreCase = true)) + } + } + + @Test + fun `colon-named skills map to nested directories`() { + val dir = tempSkillDir() + val store = DiskSkillStore(dir) + store.upsert(SkillFile(name = "backend:spring:db-base", description = "x", body = "y")) + assertTrue(File(dir, "backend/spring/db-base/SKILL.md").exists()) + } + + private fun tempSkillDir(): File { + val dir = File(System.getProperty("java.io.tmpdir"), "agentik-skills-${Uuid.random()}") + dir.deleteOnExit() + return dir + } +}