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:
@@ -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-серверов
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user