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 + } +}