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=/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-инструменты
|
## MCP-инструменты
|
||||||
|
|
||||||
`AGENTIK_MCP_CONFIG` — путь к JSON-файлу со списком 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.memory.vector.embedding.SiglipEmbeddingProvider
|
||||||
import pw.binom.agentik.server.agentikAgent
|
import pw.binom.agentik.server.agentikAgent
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
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.ChatAgent
|
||||||
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
|
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
|
||||||
import pw.binom.agentik.standalone.agent.LlmReflector
|
import pw.binom.agentik.standalone.agent.LlmReflector
|
||||||
@@ -58,11 +57,15 @@ fun main() {
|
|||||||
val llm = config.llm.createLlm()
|
val llm = config.llm.createLlm()
|
||||||
val stores = SqliteStores.open(dbPath = config.dbPath)
|
val stores = SqliteStores.open(dbPath = config.dbPath)
|
||||||
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
||||||
val skills = config.skillsDir?.let { dir ->
|
|
||||||
val result = SkillLoader.loadDirectory(File(dir))
|
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
|
||||||
result.errors.forEach { log.warn { "skill '${it.path}': ${it.message}" } }
|
// DiskSkillStore для self-improvement (`skill_save`/`skill_delete`).
|
||||||
result.catalog
|
// Один и тот же файл-каталог используется и для чтения (read_skill),
|
||||||
} ?: SkillCatalog.EMPTY
|
// и для записи — никаких рассинхронов.
|
||||||
|
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,
|
// SOUL.md — файл персоны. Если задан — читается как plain text/markdown,
|
||||||
// вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0
|
// вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0
|
||||||
@@ -172,6 +175,7 @@ fun main() {
|
|||||||
llmConfig = config.llm,
|
llmConfig = config.llm,
|
||||||
tools = mcpRegistry.namedTools,
|
tools = mcpRegistry.namedTools,
|
||||||
skills = skills,
|
skills = skills,
|
||||||
|
skillStore = skillStore,
|
||||||
memoryStore = memorySystem?.store,
|
memoryStore = memorySystem?.store,
|
||||||
memoryPrefetcher = memorySystem?.prefetcher,
|
memoryPrefetcher = memorySystem?.prefetcher,
|
||||||
memoryReviewer = memoryReviewer,
|
memoryReviewer = memoryReviewer,
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ class ChatAgent(
|
|||||||
private val llmConfig: LlmConfig,
|
private val llmConfig: LlmConfig,
|
||||||
private val tools: List<NamedTool> = emptyList(),
|
private val tools: List<NamedTool> = emptyList(),
|
||||||
private val skills: SkillCatalog = SkillCatalog.EMPTY,
|
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 memoryStore: pw.binom.agentik.memory.MemoryStore? = null,
|
||||||
private val memoryPrefetcher: MemoryPrefetcher? = null,
|
private val memoryPrefetcher: MemoryPrefetcher? = null,
|
||||||
private val memoryReviewer: MemoryReviewer? = null,
|
private val memoryReviewer: MemoryReviewer? = null,
|
||||||
@@ -101,13 +108,15 @@ class ChatAgent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные
|
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные:
|
||||||
* (`read_skill`, если есть скилы; `memory_*`, если подключена память).
|
* `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть
|
||||||
|
* [skillStore]), `memory_*` (если подключена память).
|
||||||
* MCP-тулы префиксованы `server__`, так что коллизий нет.
|
* MCP-тулы префиксованы `server__`, так что коллизий нет.
|
||||||
*/
|
*/
|
||||||
private val allTools: List<NamedTool> = buildList {
|
private val allTools: List<NamedTool> = buildList {
|
||||||
addAll(tools)
|
addAll(tools)
|
||||||
if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills)))
|
if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills)))
|
||||||
|
if (skillStore != null) addAll(SkillToolsFactory.create(skillStore))
|
||||||
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
|
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