skills: каталог + ленивая загрузка read_skill
Пользовательские инструкции («навыки») живут в указанной папке
(AGENTIK_SKILLS_DIR), рекурсивно читаются при старте и попадают в
системный промпт в сжатом виде: только имя + краткое описание.
Полный текст модель подгружает по требованию, вызывая встроенный
инструмент read_skill(name).
*:skills
- SkillCatalog + SkillPrompt (commonMain): рендер секции системного
промпта; тело навыка в промпт не течёт.
- SkillParser.parseAuto(): теперь читает и opencode-стиль SKILL.md
(YAML frontmatter + markdown тело), и голый *.yaml/*.yml
(поля name, description, опц. body). parseOrThrow для strict-путей.
- SkillParseError.render(): человекочитаемое описание ошибки для
логов и диагностики.
- SkillLoader (jvmMain): рекурсивный обход папки, детерминированный
порядок (по пути), ошибки отдельных файлов не валят загрузку;
дубликаты имён → ошибка, выигрывает первый по пути.
* :standalone
- AgentikConfig.skillsDir + env AGENTIK_SKILLS_DIR.
- ChatAgent: параметр skills (SkillCatalog); системный промпт
автоматически дополняется секцией «## Навыки» и в working memory
сидится вместе с базовым промптом.
- При непустом каталоге в tools автоматически добавляется
SkillReadTool (имя read_skill) — модель может загрузить полный
текст навыка, как обычный LiteTool.
- Main.kt: загружает навыки и шумно логирует ошибки загрузки в stderr.
* docs
- STANDALONE.md: секция «Навыки (skills)», env-переменная в таблице.
- Формат SKILL.md (opencode frontmatter) + голый *.yaml/*.yml.
Тесты: :skills jvmTest 34, :standalone jvmTest 69 (новые — состав
системного промпта, регистрация read_skill, навыки не утекают в
промпт телом).
This commit is contained in:
@@ -6,10 +6,13 @@ import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.routing
|
||||
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.config.AgentikConfig
|
||||
import pw.binom.agentik.standalone.mcp.McpRegistry
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
import java.io.File
|
||||
/**
|
||||
* standalone-контейнер agentik:
|
||||
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
||||
@@ -29,6 +32,7 @@ import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
* - AGENTIK_PORT / AGENTIK_DB_PATH
|
||||
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
|
||||
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
|
||||
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
|
||||
* - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`)
|
||||
*/
|
||||
fun main() {
|
||||
@@ -37,12 +41,18 @@ 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 { System.err.println("[agentik] skill '${it.path}': ${it.message}") }
|
||||
result.catalog
|
||||
} ?: SkillCatalog.EMPTY
|
||||
val agent = ChatAgent(
|
||||
id = "agentik",
|
||||
stores = stores,
|
||||
llm = llm,
|
||||
llmConfig = config.llm,
|
||||
tools = mcpRegistry.namedTools,
|
||||
skills = skills,
|
||||
)
|
||||
|
||||
val server = embeddedServer(CIO, port = config.port) {
|
||||
@@ -58,6 +68,7 @@ fun main() {
|
||||
println(" storage: ${config.dbPath}")
|
||||
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
|
||||
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
|
||||
println(" skills: ${skills.size} loaded${config.skillsDir?.let { " from $it" } ?: ""}")
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
agent.close()
|
||||
mcpRegistry.close()
|
||||
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.proto.Agent as ProtoAgent
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.skills.renderSystemPromptSection
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
import pw.binom.agentik.standalone.persistence.ConversationRecord
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
|
||||
@@ -25,6 +27,10 @@ import kotlin.time.Instant
|
||||
* логируют в SQLite (для долговечности).
|
||||
*
|
||||
* Один [LiteLlm] шарится между всеми беседами агента.
|
||||
*
|
||||
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в
|
||||
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для
|
||||
* загрузки полного текста навыка по требованию.
|
||||
*/
|
||||
class ChatAgent(
|
||||
override val id: String,
|
||||
@@ -32,8 +38,23 @@ class ChatAgent(
|
||||
private val llm: LiteLlm,
|
||||
private val llmConfig: LlmConfig,
|
||||
private val tools: List<NamedTool> = emptyList(),
|
||||
private val skills: SkillCatalog = SkillCatalog.EMPTY,
|
||||
) : ProtoAgent, AutoCloseable {
|
||||
|
||||
/**
|
||||
* Системный промпт + секция навыков (если скилы загружены). Именно он
|
||||
* сидируется в working memory и передаётся в [ChatConversation].
|
||||
*/
|
||||
private val systemPrompt: String = buildSystemPrompt(llmConfig.systemPrompt, skills)
|
||||
|
||||
/**
|
||||
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенный
|
||||
* `read_skill`, если есть скилы. MCP-тулы префиксованы `server__`, так что
|
||||
* коллизия с `read_skill` невозможна.
|
||||
*/
|
||||
private val allTools: List<NamedTool> =
|
||||
if (skills.isEmpty) tools else tools + NamedTool(SkillReadTool.NAME, SkillReadTool(skills))
|
||||
|
||||
private val agentEvents = MutableSharedFlow<AgentEvent>(
|
||||
extraBufferCapacity = 64,
|
||||
)
|
||||
@@ -67,12 +88,12 @@ class ChatAgent(
|
||||
stores.conversations.upsert(rec)
|
||||
stores.workingMemory.append(
|
||||
conversationId = id,
|
||||
entry = WorkingMemoryEntry.System(text = llmConfig.systemPrompt),
|
||||
entry = WorkingMemoryEntry.System(text = systemPrompt),
|
||||
now = now,
|
||||
)
|
||||
}
|
||||
}
|
||||
val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools)
|
||||
val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools)
|
||||
runBlocking {
|
||||
liveLock.withLock { live[conv.id] = conv }
|
||||
}
|
||||
@@ -83,7 +104,7 @@ class ChatAgent(
|
||||
override suspend fun getConversation(id: String): ProtoConversation? {
|
||||
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
|
||||
val rec = stores.conversations.get(id) ?: return null
|
||||
return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools).also {
|
||||
return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools).also {
|
||||
liveLock.withLock { live[id] = it }
|
||||
}
|
||||
}
|
||||
@@ -99,7 +120,7 @@ class ChatAgent(
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
|
||||
stores.conversations.list(offset = offset, limit = limit).map { rec ->
|
||||
liveLock.withLock { live[rec.id] }
|
||||
?: ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt).also {
|
||||
?: ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools).also {
|
||||
liveLock.withLock { live[rec.id] = it }
|
||||
}
|
||||
}
|
||||
@@ -120,3 +141,12 @@ class ChatAgent(
|
||||
|
||||
private fun now(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает итоговый системный промпт: базовый текст + секция навыков
|
||||
* (только если скилы есть). Пустая секция → базовый промпт без изменений.
|
||||
*/
|
||||
internal fun buildSystemPrompt(base: String, skills: SkillCatalog): String {
|
||||
val section = skills.renderSystemPromptSection()
|
||||
return if (section.isBlank()) base.trimEnd() else base.trimEnd() + "\n\n" + section
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Встроенная тула `read_skill`: отдаёт полный текст скила по имени.
|
||||
*
|
||||
* В системный промпт попадают только имя и краткое описание скилов
|
||||
* (см. `SkillCatalog.renderSystemPromptSection()`); этот тул загружает `body`
|
||||
* по требованию модели. Если имя неизвестно — возвращаем список доступных
|
||||
* имён, чтобы модель могла исправиться со следующей попытки.
|
||||
*/
|
||||
class SkillReadTool(
|
||||
private val catalog: SkillCatalog,
|
||||
) : LiteTool {
|
||||
|
||||
override fun describe(): String = buildJsonObject {
|
||||
put("type", "function")
|
||||
put("function", buildJsonObject {
|
||||
put("name", NAME)
|
||||
put(
|
||||
"description",
|
||||
"Load the full text of a skill by its name. " +
|
||||
"Use it when the task matches one of the skills listed in the system prompt. " +
|
||||
"Always read a skill before following its instructions.",
|
||||
)
|
||||
put("parameters", buildJsonObject {
|
||||
put("type", "object")
|
||||
put("properties", buildJsonObject {
|
||||
put("name", buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "Skill name exactly as listed in the system prompt.")
|
||||
})
|
||||
})
|
||||
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name"))))
|
||||
})
|
||||
})
|
||||
}.toString()
|
||||
|
||||
override fun invoke(arguments: String): String {
|
||||
val name = parseName(arguments)
|
||||
?: return "[tool error] read_skill: missing required argument \"name\""
|
||||
|
||||
val skill = catalog.find(name)
|
||||
?: return "[tool error] unknown skill \"$name\". Available skills: " +
|
||||
catalog.skills.joinToString(", ") { it.name }.ifEmpty { "<none>" }
|
||||
|
||||
return if (skill.body.isBlank()) {
|
||||
"(skill \"$name\" has an empty body)"
|
||||
} else {
|
||||
skill.body
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseName(arguments: String): String? {
|
||||
val raw = arguments.trim()
|
||||
if (raw.isEmpty()) return null
|
||||
val obj: JsonObject = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
|
||||
return (obj["name"] as? JsonPrimitive)?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Имя тула, как его видит модель. */
|
||||
const val NAME: String = "read_skill"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ data class AgentikConfig(
|
||||
val dbPath: String = DEFAULT_DB_PATH,
|
||||
val llm: LlmConfig,
|
||||
val mcp: McpConfig = McpConfig.empty(),
|
||||
/** Папка со скилами (SKILL.md / *.yaml). `null` — скилы выключены. */
|
||||
val skillsDir: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_PORT: Int = 8080
|
||||
@@ -38,6 +40,7 @@ data class AgentikConfig(
|
||||
dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH,
|
||||
llm = LlmConfig.fromEnv(env),
|
||||
mcp = McpConfig.fromEnv(env),
|
||||
skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event as ProtoEvent
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
@@ -27,6 +29,7 @@ import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
@@ -53,6 +56,7 @@ class ChatAgentTest {
|
||||
stores: SqliteStores = this.stores,
|
||||
llm: LiteLlm = this.fakeLlm,
|
||||
tools: List<NamedTool> = emptyList(),
|
||||
skills: SkillCatalog = SkillCatalog.EMPTY,
|
||||
): ChatAgent = ChatAgent(
|
||||
id = "agentik",
|
||||
stores = stores,
|
||||
@@ -63,6 +67,7 @@ class ChatAgentTest {
|
||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||
),
|
||||
tools = tools,
|
||||
skills = skills,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -77,6 +82,48 @@ class ChatAgentTest {
|
||||
assertEquals("be brief", first.entry.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skills are appended to the system prompt in working memory`() = runTest {
|
||||
val skills = SkillCatalog(
|
||||
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
||||
)
|
||||
val agent = newAgent(skills = skills)
|
||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||
|
||||
val system = stores.workingMemory.list(conv.id).first().entry
|
||||
as pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.System
|
||||
assertTrue("be brief" in system.text)
|
||||
assertTrue("## Навыки" in system.text)
|
||||
assertTrue("lint" in system.text)
|
||||
assertTrue("lint things" in system.text)
|
||||
assertFalse("SECRET BODY" in system.text, "system prompt must not leak the skill body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read_skill tool is registered when skills present`() = runTest {
|
||||
val skills = SkillCatalog(
|
||||
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
||||
)
|
||||
val agent = newAgent(skills = skills)
|
||||
val conv = agent.createConversation(temp = false)
|
||||
fakeLlm.reply = "ok"
|
||||
conv.send(listOf(Content.Text("hi")))
|
||||
|
||||
val descriptors = fakeLlm.lastConfig!!.tools.map { it.describe() }
|
||||
assertTrue(descriptors.any { SkillReadTool.NAME in it }, "expected read_skill tool: $descriptors")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no read_skill tool when skills absent`() = runTest {
|
||||
val agent = newAgent()
|
||||
val conv = agent.createConversation(temp = false)
|
||||
fakeLlm.reply = "ok"
|
||||
conv.send(listOf(Content.Text("hi")))
|
||||
|
||||
val descriptors = fakeLlm.lastConfig?.tools?.map { it.describe() } ?: emptyList()
|
||||
assertTrue(descriptors.none { SkillReadTool.NAME in it }, "unexpected read_skill tool: $descriptors")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConversation returns null for unknown id`() = runTest {
|
||||
val agent = newAgent()
|
||||
@@ -177,7 +224,7 @@ class ChatAgentTest {
|
||||
assertEquals(1, fakeLlm.conversations.size)
|
||||
val sent = fakeLlm.lastContents
|
||||
assertNotNull(sent)
|
||||
assertEquals(1, sent!!.size)
|
||||
assertEquals(1, sent.size)
|
||||
assertEquals("hello", (sent[0] as LiteContentPart.Text).text)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SkillReadToolTest {
|
||||
|
||||
private val catalog = SkillCatalog(
|
||||
listOf(
|
||||
SkillFile(name = "lint", description = "lint things", body = "# Lint\nRun the linter."),
|
||||
SkillFile(name = "empty", description = "no body", body = ""),
|
||||
),
|
||||
)
|
||||
private val tool = SkillReadTool(catalog)
|
||||
|
||||
@Test
|
||||
fun describeIsOpenAiFunctionSchema() {
|
||||
val json = tool.describe()
|
||||
assertTrue("\"type\":\"function\"" in json || "\"type\": \"function\"" in json)
|
||||
assertTrue(SkillReadTool.NAME in json)
|
||||
assertTrue("\"name\"" in json)
|
||||
assertTrue("parameters" in json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeReturnsBodyForKnownSkill() {
|
||||
val result = tool.invoke("""{"name":"lint"}""")
|
||||
assertEquals("# Lint\nRun the linter.", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeUnknownSkillListsAvailable() {
|
||||
val result = tool.invoke("""{"name":"nope"}""")
|
||||
assertTrue("unknown skill" in result)
|
||||
assertTrue("lint" in result)
|
||||
assertTrue("empty" in result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeEmptyBodyGivesPlaceholder() {
|
||||
val result = tool.invoke("""{"name":"empty"}""")
|
||||
assertTrue("empty body" in result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeMissingNameIsError() {
|
||||
val result = tool.invoke("{}")
|
||||
assertTrue("[tool error]" in result)
|
||||
assertTrue("name" in result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeInvalidJsonIsError() {
|
||||
assertTrue("[tool error]" in tool.invoke("not json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeBlankArgumentsIsError() {
|
||||
assertTrue("[tool error]" in tool.invoke(""))
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,23 @@ class AgentikConfigTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skills dir defaults to null`() {
|
||||
assertEquals(null, AgentikConfig.fromEnv(openAiEnv()).skillsDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skills dir read from env`() {
|
||||
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills")))
|
||||
assertEquals("/skills", cfg.skillsDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank skills dir falls back to null`() {
|
||||
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " ")))
|
||||
assertEquals(null, cfg.skillsDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serialization round-trips through json`() {
|
||||
val original = AgentikConfig.fromEnv(
|
||||
@@ -110,6 +127,7 @@ class AgentikConfigTest {
|
||||
"AGENTIK_PORT" to "7777",
|
||||
"AGENTIK_DB_PATH" to "/tmp/x.db",
|
||||
"AGENTIK_SYSTEM_PROMPT" to "be brief",
|
||||
"AGENTIK_SKILLS_DIR" to "/skills",
|
||||
),
|
||||
),
|
||||
).copy(
|
||||
|
||||
Reference in New Issue
Block a user