tools: подключить skill_save/skill_delete к ChatAgent (Hermes Phase 3 ready)

Тулзы были написаны ранее (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.
This commit is contained in:
2026-09-15 05:20:41 +03:00
parent 23da1f6498
commit 9afa877e39
4 changed files with 138 additions and 8 deletions
@@ -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,
@@ -50,6 +50,13 @@ class ChatAgent(
private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = 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<NamedTool> = 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))
}
@@ -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
}
}