6 Commits
11 .. 14

Author SHA1 Message Date
subochev a81d92f489 Add count methods to JournalStore API and implementations for message counting per conversation (count(conversationId) and count(conversationId, after)), with supporting tests.
release / Publish KMP libraries → caffeine Nexus (release) Failing after 32s
2026-09-23 05:31:13 +03:00
subochev 3e583ac8ea Relocate CI workflow file from .gitea/workflows/ci.yml to .gitea/ci.yml and update README.md accordingly.
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m16s
2026-09-23 04:47:43 +03:00
subochev f878d1c79b Update deployment host IP in standalone/build.gradle.kts configuration
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:46:37 +03:00
subochev 266ec38c1b Refactor: replace BackgroundScheduler with ReflectionScheduler, migrate to outbox-driven event processing, and remove skill mining logic
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:40:45 +03:00
subochev e447525059 Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s
2026-09-23 03:48:38 +03:00
subochev 84f5fd84f3 remove :storage-ksqlite (conversation/message) and related tests; decouple schema from journal
ci / JVM build + tests (push) Successful in 6m6s
2026-09-22 16:01:05 +03:00
135 changed files with 4441 additions and 2636 deletions
+7 -6
View File
@@ -18,8 +18,9 @@ agentik/
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...) ├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск) ├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...) ├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
├── storage-inmemory/ in-memory реализация для тестов и Android │ (исторический, см. journal-api / context-api / reflection-api ниже)
├── storage-sqlite/ SQLite реализация для production ├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget ├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik ├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17 ├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
@@ -89,9 +90,9 @@ curl http://localhost:8080/health
- [`:memory-api`](memory-api/README.md) — контракт памяти. - [`:memory-api`](memory-api/README.md) — контракт памяти.
- [`:memory-md`](memory-md/README.md) — Hermes-style файл. - [`:memory-md`](memory-md/README.md) — Hermes-style файл.
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector. - [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
- [`:storage-core`](storage-core/README.md) — контракт storage. - [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация. - ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production. - ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер. - [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
## Где смотреть версии ## Где смотреть версии
@@ -124,7 +125,7 @@ SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппирован
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`): Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка - `.gitea/ci.yml` — PR-build, прогон тестов, проверка
shadowjar'ов. shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты - `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу. в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
-37
View File
@@ -1,37 +0,0 @@
Status of message-log-api migration:
DONE:
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
- Content.kt (sealed: Text, Image)
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
- MessageStore.kt (interface, TokenStats, MessageEvent)
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
6. Wrote /tmp/run_rename.sh that runs the python script.
PENDING:
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
- Verify build compiles (run standalone tests).
Files that need import updates (per search):
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
- standalone/src/jvmMain/.../ToolDispatcher.kt
- standalone/src/jvmMain/.../ConversationLoop.kt
- standalone/src/jvmTest/.../ChatAgentTest.kt
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
Workaround needed before continuing the migration.
+42
View File
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :proto — read-only Agent interface, который MutableAgent расширяет.
// Через api(), иначе downstream-impl ChatAgent не сможет
// override suspend-методы Agent.
api(project(":proto"))
// :memory-api — typealias ConversationTurn на memory-api одноимённый
// класс, иначе пер-конво компоненты (skill mining, reflection) не
// смогут передать его в SkillMiner.mine() напрямую.
api(project(":memory-api"))
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
api(libs.litert.api)
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
// listener) — оставлен как api, чтобы downstream не забывал объявить.
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.agent
/**
* Нашлёпка поверх [MutableAgent].
*
* Компонент сам регистрирует в агенте свои capability-провайдеры
* при [install] и снимает их при [uninstall]. Агент не знает заранее
* ни о структуре компонента, ни о его провайдерах — это просто
* хук для свободной композиции.
*
* Ktor-style API:
* ```
* val agent = ChatAgent(...)
* .install(SkillComponent(store, miner))
* .install(ReflectionComponent(reflectionStore, reflector))
* .install(MemoryComponent(memorySystem))
* ```
*
* Контракт:
* - [install] **синхронен**: компонент добавляет свои провайдеры в
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
* фоновые корутины — компонент запускает их через свой собственный
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
*/
interface Component {
/**
* Вызывается агентом при [MutableAgent.install].
*
* Типичные действия: добавить [SystemPromptProvider] в
* `agent.systemProviders`, добавить [ToolProvider] в
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
*/
fun install(agent: MutableAgent)
/**
* Вызывается агентом при [MutableAgent.uninstall] или при
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
* которые добавил в [install], и остановить фоновые джобы.
*/
fun uninstall(agent: MutableAgent)
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.agent
/**
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
*
* [MutableAgent] при создании/закрытии разговора вызывает
* [attachConversation] / [detachConversation] на каждом компоненте,
* реализующем этот интерфейс. Внутри компонент хранит
* [ConversationHandle] (или контекст вокруг него) и подписывается на
* нужные события.
*
* Компонент без [ConversationAware] остаётся чисто agent-level — он
* не получает per-conversation хуков.
*/
interface ConversationAware {
fun attachConversation(handle: ConversationHandle)
fun detachConversation(handle: ConversationHandle)
}
/**
* Минимальное окно в разговор, которое компонент видит через
* [ConversationAware]. Содержит только то, что нужно большинству
* per-conversation компонентов:
* - идентификатор (для подписки на события),
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
* - последние N turns (для LlmReflector / SkillMiner).
*
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
* чтобы компонент не лез в чужие обязанности.
*/
interface ConversationHandle : AutoCloseable {
val id: String
val isTemporal: Boolean
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
suspend fun recentTurns(limit: Int): List<ConversationTurn>
override fun close()
}
/**
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
* компоненты (skill mining, reflection) могли передавать его напрямую
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
* конвертации.
*/
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
@@ -0,0 +1,78 @@
package pw.binom.agentik.agent
import pw.binom.agentik.proto.Agent
/**
* Настраиваемая версия [Agent]: расширяет публичный contract агента
* install/uninstall-механикой компонентов ([Component]).
*
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
*
* Контракт:
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
*
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
* позже, в v1 держим KISS.
*/
interface MutableAgent : Agent {
/**
* Провайдеры секций system prompt, регистрируются компонентами через [install].
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
* system prompt конкретной беседы; возвращает `null`, если у него нет
* релевантной секции для данного контекста.
*
* Изменяется **только внутри `Component.install(this)` /
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
* напрямую в список не лезет.
*/
val systemProviders: MutableList<SystemPromptProvider>
/**
* Провайдеры tools, регистрируются компонентами через [install].
* [ToolProvider.tools] вызывается при формировании набора тулов
* для конкретной беседы; компонент решает сам, какие тулы отдавать
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
*/
val toolProviders: MutableList<ToolProvider>
/**
* Устанавливает [component] в агент: `component.install(this)` +
* агент запоминает компонент, чтобы при [close] корректно его снять.
*
* Возвращает `this` — для fluent-цепочек:
* ```
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
* ```
*/
fun install(component: Component): MutableAgent
/**
* Снимает [component]: `component.uninstall(this)` + забывает.
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
*/
fun uninstall(component: Component): MutableAgent
/**
* Оповещает все установленные компоненты, реализующие [ConversationAware],
* о появлении нового разговора. Компонент может подписаться на события,
* запустить фоновые задачи, проиндексировать turns и т.п.
*/
fun attachConversation(handle: ConversationHandle)
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
fun detachConversation(handle: ConversationHandle)
/**
* Освобождает ресурсы агента и снимает все установленные компоненты
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
* первым). Idempotent.
*/
override fun close()
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.agent
/**
* Провайдер одной секции system prompt конкретной беседы.
*
* Вызывается [MutableAgent] при каждом построении system prompt
* (на старте беседы и после значимых изменений контекста). Возвращает
* либо markdown-строку секции (будет вставлена в system prompt в порядке
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
* либо `null`, если у провайдера нет релевантной секции для данного
* контекста (например, skill-каталог пуст).
*
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
* страх и риск, **не** рекомендуется в v1).
*/
fun interface SystemPromptProvider {
/**
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
*
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
* если провайдер хочет делать per-conversation разделение, он может.
*/
fun getSection(conversationId: String): String
}
@@ -0,0 +1,33 @@
package pw.binom.agentik.agent
import pw.binom.litert.LiteTool
/**
* Провайдер набора тулов конкретной беседы.
*
* Вызывается [MutableAgent] при формировании списка тулов, доступных
* модели в данной беседе (на старте и при пересборке после существенных
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
* из `describe()` / `invoke()` того же объекта.
*
* Не-suspend: типичная реализация строит список тулов из in-memory state
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
* к state компонент использует свой собственный scope и кэш.
*
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
* лишним слоем и удалён.
*/
fun interface ToolProvider {
/**
* Возвращает список тулов, доступных модели в беседе [conversationId].
*
* Провайдер может делать per-conversation фильтрацию (например, скрывать
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
* пустой список.
*/
fun getTools(conversationId: String): List<LiteTool>
}
+3
View File
@@ -24,9 +24,12 @@ kotlin {
api(project(":journal-api")) api(project(":journal-api"))
api(project(":reflection-api")) api(project(":reflection-api"))
api(project(":context-api")) api(project(":context-api"))
api(project(":agent-api"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke) // litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api) api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
api(libs.litert.tools.kotlinx.serialization)
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core) api(libs.kotlinx.serialization.core)
@@ -1,5 +1,6 @@
package pw.binom.agentik.toolsets package pw.binom.agentik.toolsets
import kotlinx.serialization.Serializable
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
/** /**
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
*/ */
class DisableToolsetTool(private val registry: ToolsetRegistry) { class DisableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool( val tool: LiteTool = liteToolSuspend<DisableArgs>(
describeJson = DESCRIBE, name = NAME,
handler = ::invoke, description = "Deactivate a toolset by name. Its tools become unavailable.",
) ) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String { internal suspend fun invoke(args: DisableArgs): String {
val name = parseName(args) ?: return "missing required argument 'name'" val name = args.name
val toolset = registry.findByName(name) val toolset = registry.findByName(name)
if (toolset != null) { if (toolset != null) {
// Единообразный ответ независимо от текущего состояния.
registry.deactivate(name) registry.deactivate(name)
return "Toolset '$name' deactivated." return "Toolset '$name' deactivated."
} }
// Неизвестный — перечисляем активные (что можно деактивировать)
val actives = registry.activeNames() val actives = registry.activeNames()
return if (actives.isEmpty()) { return if (actives.isEmpty()) {
"Toolset '$name' not found. No toolsets to deactivate." "Toolset '$name' not found. No toolsets to deactivate."
@@ -39,11 +40,10 @@ class DisableToolsetTool(private val registry: ToolsetRegistry) {
} }
} }
@Serializable
internal data class DisableArgs(val name: String)
companion object { companion object {
const val NAME: String = "disable_toolset" const val NAME: String = "disable_toolset"
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
""".trimIndent()
} }
} }
@@ -1,8 +1,6 @@
package pw.binom.agentik.toolsets package pw.binom.agentik.toolsets
import kotlinx.serialization.json.Json import kotlinx.serialization.Serializable
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
/** /**
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
*/ */
class EnableToolsetTool(private val registry: ToolsetRegistry) { class EnableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool( val tool: LiteTool = liteToolSuspend<EnableArgs>(
describeJson = DESCRIBE, name = NAME,
handler = ::invoke, description = "Activate a toolset by name to access its tools.",
) ) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String { internal suspend fun invoke(args: EnableArgs): String {
val name = parseName(args) ?: return "missing required argument 'name'" val name = args.name
val toolset = registry.findByName(name) val toolset = registry.findByName(name)
if (toolset != null) { if (toolset != null) {
val wasActive = registry.isActive(name) val wasActive = registry.isActive(name)
registry.activate(name) registry.activate(name)
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated." return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
} }
// Неизвестный — перечисляем доступные к активации (inactives)
val inactives = registry.inactiveNames() val inactives = registry.inactiveNames()
return if (inactives.isEmpty()) { return if (inactives.isEmpty()) {
"Toolset '$name' not found. No toolsets available for activation." "Toolset '$name' not found. No toolsets available for activation."
@@ -42,23 +41,10 @@ class EnableToolsetTool(private val registry: ToolsetRegistry) {
} }
} }
@Serializable
internal data class EnableArgs(val name: String)
companion object { companion object {
const val NAME: String = "enable_toolset" const val NAME: String = "enable_toolset"
/**
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
* Соответствует litert-kmp формату LiteTool.describe().
*/
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
""".trimIndent()
} }
} }
/**
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
* Возвращает null если отсутствует или не строка.
*/
internal fun parseName(argsJson: String): String? = runCatching {
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
}.getOrNull()
@@ -1,15 +0,0 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* (имя-как-видит-модель) → [LiteTool].
*
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами.
*
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
* должен жить рядом с другими тулами в `:agent-toolsets`.
*/
data class NamedTool(val name: String, val tool: LiteTool)
@@ -2,9 +2,10 @@ package pw.binom.agentik.toolsets
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import pw.binom.litert.tools.kotlinx.serialization.liteTool
/** /**
* Адаптер из suspend-handler'а в синхронный [LiteTool]. * Обёртка из suspend-handler'а в синхронный [LiteTool].
* *
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это * `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт * упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам * `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов. * LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
* *
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
* который генерит JSON-схему из `@Serializable Args` через
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
* тулов, чьи описания не получается выразить через `Args` (например, динамические
* JSON Schema, приходящие со стороны).
*
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать * Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста. * `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
*/ */
internal class SyncLiteTool( internal class SyncLiteTool(
override val name: String,
private val describeJson: String, private val describeJson: String,
private val handler: suspend (String) -> String, private val handler: suspend (String) -> String,
) : LiteTool { ) : LiteTool {
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
} }
/** /**
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика. * Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено * JSON-схема генерится автоматически из `Args.descriptor`,
* как API-точка чтобы внешний код не зависел от internal-имени класса. * а сырая строка аргументов десериализуется в типизированный [Args].
*
* Использование:
* ```
* val t: LiteTool = liteToolSuspend<MyArgs>(name = "foo", description = "...") { args ->
* suspendBlock(args) // MyArgs уже распарсен
* }
* ```
*
* Реализация: под капотом используется [pw.binom.litert.tools.kotlinx.serialization.liteTool] —
* его sync-handler запускает наш suspend-handler в [runBlocking].
*/ */
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool = inline fun <reified Args> liteToolSuspend(
SyncLiteTool(describeJson, handler) name: String,
description: String = "",
noinline handler: suspend (Args) -> String,
): LiteTool = liteTool<Args>(
name = name,
description = description,
) { args ->
runBlocking { handler(args) }
}
@PublishedApi
internal val invocationJson: kotlinx.serialization.json.Json = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
isLenient = false
coerceInputValues = true
explicitNulls = false
}
@@ -0,0 +1,103 @@
package pw.binom.agentik.toolsets
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.litert.LiteTool
/**
* Подключает механику toolsets к агенту:
* - [ToolsetRegistry] (per-component instance — раньше жил в ChatAgent).
* - Тулы [EnableToolsetTool] и [DisableToolsetTool] всегда доступны — модель
* ими переключает состояние.
* - Тулы активных тулсетов — динамически: после `enable_toolset(name=X)`
* X.tools становятся видны через [ToolProvider.getTools] уже на
* следующем turn'е.
* - Секция системного промпта — список активных/неактивных тулсетов,
* чтобы модель знала что включено.
*
* Один [ToolsetComponent] на агента. Шарится между беседами через общий
* [MutableAgent] (все conversations читают один [ToolsetRegistry]).
*
* `install(agent)` идемпотентно. `uninstall(agent)` снимает оба провайдера
* по типу (см. [ToolsetToolProvider], [ToolsetSystemProvider]).
*/
class ToolsetComponent(
private val contributions: List<ToolsetContribution>,
) : Component {
/**
* Реестр тулсетов, владеет [ToolsetComponent]. `private` — наружу не светится,
* чтобы никто не дёргал его мимо `enable_toolset`/`disable_toolset` тулов.
*/
private val registry: ToolsetRegistry = ToolsetRegistry(contributions)
private var provider: ToolsetToolProvider? = null
override fun install(agent: MutableAgent) {
val p = ToolsetToolProvider(registry, contributions)
agent.toolProviders.add(p)
provider = p
agent.systemProviders.add(ToolsetSystemProvider(registry))
}
override fun uninstall(agent: MutableAgent) {
provider?.let { agent.toolProviders.remove(it) }
agent.systemProviders.removeAll { it is ToolsetSystemProvider }
}
}
/**
* Возвращает тулсет-тулы в зависимости от текущего состояния реестра:
* - `enable_toolset` / `disable_toolset` — всегда.
* - Тулы активных тулсетов — те, что перечислены в [ToolsetRegistry.activeNames].
*
* Snapshot собирается на каждом вызове [getTools] — диспетчер видит свежее
* состояние после `enable_toolset` уже на следующем turn'е.
*/
class ToolsetToolProvider(
private val registry: ToolsetRegistry,
private val contributions: List<ToolsetContribution>,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> = buildList {
add(EnableToolsetTool(registry).tool)
add(DisableToolsetTool(registry).tool)
// Активные тулсеты — добавляем их тулы в общий пул. Это синхронная
// версия (lock-free snapshot), потому что `getTools` вызывается
// синхронно из `collectTools()`; `active` сам по себе Concurrent-Set
// через Mutex в реестре (все мутации — через activate/deactivate).
val active = runBlockingSnapshot()
contributions.filter { it.name in active }.forEach { c ->
c.tools.forEach { add(it.tool) }
}
}
/**
* Снимает снимок активных имён без suspend-блокировки.
* ToolsetRegistry.activeNames() — suspend, но его можно обойти если
* вычислить через прямой snapshot — для простоты используем runBlocking.
* Это всё равно вызывается на каждый turn, но мьютекс короткий.
*/
private fun runBlockingSnapshot(): Set<String> = kotlinx.coroutines.runBlocking {
registry.activeNames().toSet()
}
}
/**
* Секция системного промпта с описанием доступных тулсетов:
* - `*active*` — что уже подключено.
* - `*inactive*` — что доступно через `enable_toolset`.
*/
class ToolsetSystemProvider(
private val registry: ToolsetRegistry,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String {
val activeNames = kotlinx.coroutines.runBlocking { registry.activeNames() }.toSet()
val all = registry.all()
val active = all.filter { it.name in activeNames }
val inactive = all.filter { it.name !in activeNames }
return SystemPromptToolsetSection.render(active = active, inactive = inactive) ?: ""
}
}
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class DisableToolsetToolTest { class DisableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok" override fun invoke(arguments: String) = "ok"
} }
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
reg.activate("media") reg.activate("media")
val r = disable.invoke("""{"name":"media"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
assertEquals("Toolset 'media' deactivated.", r) assertEquals("Toolset 'media' deactivated.", r)
assertEquals(false, reg.isActive("media")) assertEquals(false, reg.isActive("media"))
} }
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf( val (disable, _) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
// тулсет изначально неактивен — должно быть тот же ответ (uniform) val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
val r = disable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' deactivated.", r) assertEquals("Toolset 'media' deactivated.", r)
} }
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
)) ))
reg.activate("a") reg.activate("a")
reg.activate("b") reg.activate("b")
val r = disable.invoke("""{"name":"unknown"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r) assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
} }
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf( val (disable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()), ToolsetContribution("a", "x", emptyList()),
)) ))
val r = disable.invoke("""{"name":"unknown"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r) assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
} }
@Test
fun `missing name argument returns error message`() = runTest {
val (disable, _) = harness(emptyList())
val r = disable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
} }
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class EnableToolsetToolTest { class EnableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok" override fun invoke(arguments: String) = "ok"
} }
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
val (enable, reg) = harness(listOf( val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))), ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
)) ))
val r = enable.invoke("""{"name":"media"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' activated.", r) assertEquals("Toolset 'media' activated.", r)
assertEquals(true, reg.isActive("media")) assertEquals(true, reg.isActive("media"))
} }
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
reg.activate("media") reg.activate("media")
val r = enable.invoke("""{"name":"media"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' already active.", r) assertEquals("Toolset 'media' already active.", r)
} }
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
ToolsetContribution("b", "y", emptyList()), ToolsetContribution("b", "y", emptyList()),
ToolsetContribution("c", "z", emptyList()), ToolsetContribution("c", "z", emptyList()),
)) ))
val r = enable.invoke("""{"name":"unknown"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r) assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
} }
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
ToolsetContribution("a", "x", emptyList()), ToolsetContribution("a", "x", emptyList()),
)) ))
reg.activate("a") reg.activate("a")
val r = enable.invoke("""{"name":"unknown"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r) assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
} }
@Test
fun `missing name argument returns error message`() = runTest {
val (enable, _) = harness(emptyList())
val r = enable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
} }
@@ -11,6 +11,7 @@ import kotlin.test.assertTrue
class ToolsetDispatchPolicyTest { class ToolsetDispatchPolicyTest {
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool { private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response override fun invoke(arguments: String) = response
} }
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
class ToolsetRegistryTest { class ToolsetRegistryTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok:$name" override fun invoke(arguments: String) = "ok:$name"
} }
+2 -2
View File
@@ -47,8 +47,8 @@ val moduleDescriptions: Map<String, String> = mapOf(
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).", "memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).", "memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).", "storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).", "storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).", "storage-sqlite" to "agentik :storage-sqlite — исторический модуль (deleted 2026-09-22; ksqlite-реализации теперь живут в :journal-ksqlite / :context-ksqlite / :reflection-ksqlite).",
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.", "agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.", "agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)." // "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
+1 -1
View File
@@ -444,7 +444,7 @@ agent.deleteConversation(conv.id) // → DELETE /conversations/
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:storage-ksqlite` как образец. `:journal-ksqlite` как образец.
```kotlin ```kotlin
import pw.binom.agentik.journal.MutableConversationStore import pw.binom.agentik.journal.MutableConversationStore
+1 -1
View File
@@ -10,7 +10,7 @@ plugins {
// агента. // агента.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -17,20 +17,41 @@ import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [ContextStore] (таблица `working_memory`). * ksqlite-реализация [ContextStore] (таблица `working_memory`).
* *
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore] * Единственный владелец таблицы `working_memory` в проекте. Используется
* из `:storage-ksqlite`, но: * напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
* - лежит в собственном модуле `:context-ksqlite`; * `pw.binom.agentik.standalone.persistence.SqliteStores`.
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
* теперь главный класс — `ContextStore`); сами типы строк
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
* *
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске — * ## Lifecycle соединения
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно. *
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close].
* - `KsqliteContextStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
*/ */
class KsqliteContextStore( class KsqliteContextStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : ContextStore { ) : ContextStore {
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -179,6 +200,15 @@ class KsqliteContextStore(
maxOrderIdxStmt.close() maxOrderIdxStmt.close()
dropFromIdxStmt.close() dropFromIdxStmt.close()
insertSummaryStmt.close() insertSummaryStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteContextStore =
KsqliteContextStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
private fun maxOrderIdx(conversationId: String): Long { private fun maxOrderIdx(conversationId: String): Long {
@@ -12,7 +12,7 @@ import pw.binom.db.ksqlite.SQLiteConnection
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких * Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е. * хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/ */
internal object Schema { object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1 const val CURRENT_VERSION: Int = 1
@@ -12,16 +12,9 @@ import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Тесты для [KsqliteContextStore] — точная копия * Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием * SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для * прогоняет `Schema.migrate` в init, явный вызов не нужен.
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
* та же).
*
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
* автономный.
*/ */
class KsqliteContextStoreTest { class KsqliteContextStoreTest {
@@ -31,7 +24,6 @@ class KsqliteContextStoreTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}") conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteContextStore(conn) store = KsqliteContextStore(conn)
} }
+6 -2
View File
@@ -6,7 +6,7 @@ kotlinx-io = "0.8.0"
ktor = "3.1.3" ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT" a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0" kaml = "0.104.0"
litert = "8" litert = "13"
sqldelight = "2.3.2" sqldelight = "2.3.2"
shadow = "8.3.5" shadow = "8.3.5"
jvector = "3.0.6" jvector = "3.0.6"
@@ -40,6 +40,9 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" } litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" } litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" } litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
# liteTool(liteToolRaw) DSL: типизированные LiteTool через @Serializable args.
# https://git.binom.pw/subochev/litert-kmp/src/branch/main/litert-tools-kotlinx-serialization
litert-tools-kotlinx-serialization = { module = "pw.binom.litert:litert-tools-kotlinx-serialization", version.ref = "litert" }
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver --- # --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
@@ -109,5 +112,6 @@ text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" } text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). --- # --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" } # KMP-артефакт (он же `kotlin-logging-jvm` существует отдельно как JVM-only build).
kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
@@ -18,6 +18,28 @@ interface JournalStore : AutoCloseable {
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord> suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/**
* Сколько сообщений в диалоге [conversationId] всего.
*
* O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`),
* O(N) на in-memory (size простого list'а с фильтром по conversationId).
* Не зависит от cursor'а [after] — для total-размера диалога.
*/
suspend fun count(conversationId: String): Long
/**
* Сколько сообщений в диалоге [conversationId] создано **позже** [after]
* (строго `createdAt > after`, как и в [list]).
*
* O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для:
* - UI badge "N новых сообщений" — клиент знает последний `lastSeen`,
* сервер говорит `count(convId, after=lastSeen)`;
* - пагинации без получения самих записей: знаем лимит последней страницы,
* надо понять "есть ли ещё";
* - compaction-метрик: «сколько turn'ов осталось после cutoff».
*/
suspend fun count(conversationId: String, after: Instant): Long
/** /**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip * Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для * (по странице через `list()` пока не получит короткую страницу). Для
@@ -14,4 +14,12 @@ package pw.binom.agentik.journal
*/ */
interface MutableJournalStore : JournalStore { interface MutableJournalStore : JournalStore {
suspend fun append(record: MessageRecord) suspend fun append(record: MessageRecord)
/**
* Удалить все сообщения диалога [conversationId]. Используется
* владельцем lifecycle диалога при его удалении (каскад из
* ChatAgent.deleteConversation). Append-only природа audit log'а
* не нарушается — это bulk-clear, а не редактирование.
*/
suspend fun clear(conversationId: String)
} }
@@ -54,9 +54,17 @@ class InMemoryJournalStore : MutableJournalStore {
.toList() .toList()
} }
/** Сбросить кэш (например, когда диалог удалён). */ /** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
suspend fun clear(): Unit = mutex.withLock { override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.clear() records.removeAll { it.conversationId == conversationId }
}
override suspend fun count(conversationId: String): Long = mutex.withLock {
records.count { it.conversationId == conversationId }.toLong()
}
override suspend fun count(conversationId: String, after: Instant): Long = mutex.withLock {
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
} }
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */ /** Сколько записей сейчас в кэше. Для тестов/диагностики. */
@@ -29,7 +29,7 @@ class InMemoryMutableConversationStore(
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) : MutableConversationStore { ) : MutableConversationStore {
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf() private val byId = mutableMapOf<String, ConversationRecord>()
private val mutex = Mutex() private val mutex = Mutex()
override suspend fun upsert(record: ConversationRecord) { override suspend fun upsert(record: ConversationRecord) {
@@ -66,13 +66,96 @@ class InMemoryJournalStoreTest {
} }
@Test @Test
fun `clear empties the cache`() = runTest { fun `clear empties a conversation only`() = runTest {
val store = InMemoryJournalStore() val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z") val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "x", t0)) store.append(userMsg("m1", "c1", "x", t0))
store.append(userMsg("m2", "c2", "y", t0))
assertEquals(2, store.size())
store.clear("c1")
assertEquals(1, store.size()) assertEquals(1, store.size())
store.clear()
assertEquals(0, store.size())
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty()) assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
}
@Test
fun `count returns total per conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
assertEquals(0L, store.count("c1"))
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 1.seconds))
store.append(userMsg("m3", "c2", "c", t0 + 2.seconds))
assertEquals(2L, store.count("c1"))
assertEquals(1L, store.count("c2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun `count is unaffected by clear of another conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..20) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
store.append(userMsg("n1", "c2", "y", t0))
assertEquals(20L, store.count("c1"))
assertEquals(1L, store.count("c2"))
store.clear("c1")
assertEquals(0L, store.count("c1"))
assertEquals(1L, store.count("c2"))
}
@Test
fun `count after cursor excludes earlier messages`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
// strictly > t0
assertEquals(2L, store.count("c1", t0))
// strictly > t0+10s — only the last
assertEquals(1L, store.count("c1", t0 + 10.seconds))
// after last — empty
assertEquals(0L, store.count("c1", t0 + 20.seconds))
// distant past — all
assertEquals(3L, store.count("c1", Instant.DISTANT_PAST))
}
@Test
fun `count after cursor scopes to conversation`() = runTest {
val store = InMemoryJournalStore()
val t = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t))
store.append(userMsg("m2", "c2", "b", t))
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("c1", future))
assertEquals(0L, store.count("c2", future))
assertEquals(1L, store.count("c1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("c2", Instant.DISTANT_PAST))
}
@Test
fun `count agrees with list size`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..15) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
assertEquals(
store.list("c1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("c1"),
)
val cutoff = t0 + 7.seconds
assertEquals(
store.list("c1", cutoff, 0, 1000).size.toLong(),
store.count("c1", cutoff),
)
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ plugins {
// собственных ksqlite-модулях. // собственных ksqlite-модулях.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -14,31 +14,67 @@ import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [MutableJournalStore] (append-only audit log). * ksqlite-реализация [MutableJournalStore] (append-only audit log).
* *
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore] * Единственный класс для message-таблицы. Используется напрямую через
* из `:storage-ksqlite`, но: * `:journal-ksqlite` зависимость; bundle'ом собирает
* - лежит в собственном модуле `:journal-ksqlite`; * `pw.binom.agentik.standalone.persistence.SqliteStores`.
* - реализует переименованный [MutableJournalStore] (раньше был *
* `MutableMessageStore`, теперь главный класс — `JournalStore` / * ## Lifecycle соединения
* `MutableJournalStore`); сам тип записи [MessageRecord] не *
* переименовывался. * Три формы конструктора с разной семантикой владения:
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
* где один connection используется многими store'ами и закрывается bundle'ом.
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
* его в [close].
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
* закрывает его в [close].
*
* ## Миграция
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
* прогоняют миграцию одной и той же схемы по очереди.
* *
* Prepared statements (insert / list / clear) препарируются один раз в * Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого * конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже * GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]). * parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
* (см. [pw.binom.db.ksqlite.StmtHolder]).
* *
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding * `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат * helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом. * в [MessageCodecs.kt] рядом.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
* отдельно.
*/ */
class KsqliteJournalStore internal constructor( class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableJournalStore { ) : MutableJournalStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`), где один
* connection используется многими store'ами и закрывается bundle'ом.
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -64,6 +100,16 @@ class KsqliteJournalStore internal constructor(
private val clearStmt: SQLitePreparedStatement = connection.prepare( private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?" "DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
) )
private val countAllStmt: SQLitePreparedStatement = connection.prepare(
"SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val countAfterStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
""".trimIndent()
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) { override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record) val (kind, payload) = encodeRecord(record)
@@ -94,13 +140,15 @@ class KsqliteJournalStore internal constructor(
listStmt.bindLong(4, offset.toLong()) listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>() val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs -> listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json)) while (rs.next()) {
out.add(rs.toMessageRecord(json))
}
} }
out out
} }
} }
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) { override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
clearStmt.reset() clearStmt.reset()
clearStmt.clearBindings() clearStmt.clearBindings()
@@ -109,9 +157,52 @@ class KsqliteJournalStore internal constructor(
} }
} }
override suspend fun count(conversationId: String): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAllStmt.reset()
countAllStmt.clearBindings()
countAllStmt.bindText(1, conversationId)
countAllStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override suspend fun count(conversationId: String, after: Instant): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAfterStmt.reset()
countAfterStmt.clearBindings()
countAfterStmt.bindText(1, conversationId)
countAfterStmt.bindLong(2, after.toEpochMilliseconds())
countAfterStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override fun close() { override fun close() {
insertStmt.close() insertStmt.close()
listStmt.close() listStmt.close()
clearStmt.close() clearStmt.close()
countAllStmt.close()
countAfterStmt.close()
if (ownsConnection) {
connection.close()
}
}
companion object {
/**
* Открывает in-memory соединение через [SQLiteConnection.memory] и
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
* runtime.
*/
fun memory(name: String? = null) =
KsqliteJournalStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
} }
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -14,13 +14,50 @@ import kotlinx.coroutines.withContext
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт * ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
* в [Schema] (миграция через PRAGMA user_version) — этот класс только * в [Schema] (миграция через PRAGMA user_version) — этот класс только
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`. * готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
*
* Каскадное удаление связанных данных (message + working_memory) делает
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
* store знает только про свою таблицу.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
* store НЕ закрывает его в [close] (используется shared-connection
* bundle'ом `SqliteStores.assemble`).
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
* в [close].
*/ */
class KsqliteMutableConversationStore( class KsqliteMutableConversationStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val messageStore: KsqliteMessageStore? = null, private val ownsConnection: Boolean,
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
) : MutableConversationStore { ) : MutableConversationStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`).
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см. // pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
@@ -125,8 +162,6 @@ class KsqliteMutableConversationStore(
// Проверяем существование через raw query, НЕ через get() — get() тоже // Проверяем существование через raw query, НЕ через get() — get() тоже
// берёт mutex (не реентрант), что привело бы к deadlock. // берёт mutex (не реентрант), что привело бы к deadlock.
if (!execExists(id)) return@withContext false if (!execExists(id)) return@withContext false
messageStore?.clear(id)
workingMemoryStore?.clear(id)
deleteStmt.reset() deleteStmt.reset()
deleteStmt.clearBindings() deleteStmt.clearBindings()
deleteStmt.bindText(1, id) deleteStmt.bindText(1, id)
@@ -188,6 +223,15 @@ class KsqliteMutableConversationStore(
renameStmt.close() renameStmt.close()
renameUpdatedAtStmt.close() renameUpdatedAtStmt.close()
touchStmt.close() touchStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteMutableConversationStore =
KsqliteMutableConversationStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
private fun execExists(id: String): Boolean { private fun execExists(id: String): Boolean {
@@ -10,10 +10,8 @@ import kotlin.time.Instant
/** /**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite. * Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
* *
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers * `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор. * не в каком-то внешнем общем модуле.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
*/ */
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) { internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload( is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
@@ -5,32 +5,51 @@ import pw.binom.db.ksqlite.SQLiteConnection
/** /**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`. * Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
* *
* Минимум — только то, что относится к `message` (append-only audit log). * Владеет двумя таблицами:
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`) * - `conversation` — реестр диалогов агента (см. ConversationRecord);
* живут в других ksqlite-модулях. * - `message` — append-only audit log сообщений диалогов.
*
* `working_memory` и `reflection` живут в других ksqlite-модулях.
* *
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких * Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е. * хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/ */
internal object Schema { object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1 const val CURRENT_VERSION: Int = 1
// ───── Таблица ───── // ───── Таблицы ─────
const val TABLE_CONVERSATION = "conversation"
const val TABLE_MESSAGE = "message" const val TABLE_MESSAGE = "message"
// ───── Колонки ───── // ───── Колонки conversation ─────
const val COL_ID = "id" const val COL_ID = "id"
const val COL_TITLE = "title"
const val COL_IS_TEMPORAL = "is_temporal"
const val COL_CREATED_AT = "created_at"
const val COL_UPDATED_AT = "updated_at"
// ───── Колонки message ─────
const val COL_CONVERSATION_ID = "conversation_id" const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind" const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json" const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ───── // ───── Индексы ─────
const val IDX_CONV_UPDATED = "idx_conv_updated"
const val IDX_MSG_CONV = "idx_msg_conv" const val IDX_MSG_CONV = "idx_msg_conv"
private val v1Ddl = """ private val v1ConversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_TITLE TEXT,
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
"""
private val v1MessageDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE ( CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY, $COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL, $COL_CONVERSATION_ID TEXT NOT NULL,
@@ -38,54 +57,43 @@ internal object Schema {
$COL_PAYLOAD_JSON TEXT NOT NULL, $COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL $COL_CREATED_AT INTEGER NOT NULL
); );
""".trimIndent() """
private val v1IndexesDdl = """ private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
-- сортировка по created_at (используется list(), cascade-clear, etc.)
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT); ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
""".trimIndent() """
/** /**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД. * Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
* *
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм, * Гарантии:
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция — * - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция * уже-мигрированной БД;
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии. * - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной.
* *
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op. **NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
* user_version как gate перестал работать — два модуля ставят его в 1,
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
*/ */
fun migrate(conn: SQLiteConnection) { fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN") conn.exec("BEGIN")
try { try {
if (current < 1) { conn.exec(v1ConversationDdl)
conn.exec(v1Ddl) conn.exec(v1MessageDdl)
conn.exec(v1IndexesDdl) conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT") conn.exec("COMMIT")
} catch (t: Throwable) { } catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") } runCatching { conn.exec("ROLLBACK") }
throw t throw t
} }
} }
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
} }
@@ -13,15 +13,9 @@ import kotlin.test.assertEquals
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Тесты для [KsqliteJournalStore] — точная копия * Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов * SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory * прогоняет `Schema.migrate` в init, явный вызов не нужен.
* SQLiteConnection + Schema.migrate).
*
* Тест `testClearRemovesByConversation` из оригинала использовал
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
* автономен и не знает про ConversationStore.
*/ */
class KsqliteJournalStoreTest { class KsqliteJournalStoreTest {
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}") conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteJournalStore(conn) store = KsqliteJournalStore(conn)
} }
@@ -103,4 +96,93 @@ class KsqliteJournalStoreTest {
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList()) assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size) assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
} }
@Test
fun testCountReturnsTotalForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
assertEquals(0L, store.count("conv1"))
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t, null))
store.append(MessageRecord.UserMessage("m3", "conv2", listOf(Content.Text("c")), t, null))
assertEquals(2L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("missing"))
}
@Test
fun testCountIsolatedFromOtherConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
// Bulk insert into conv1, single into conv2.
for (i in 1..50) {
store.append(MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t, null))
}
store.append(MessageRecord.UserMessage("n1", "conv2", listOf(Content.Text("only")), t, null))
assertEquals(50L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun testCountAfterFiltersStrictly() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
// Strictly > — t1 is not counted when after=t1.
assertEquals(2L, store.count("conv1", t1))
// Strictly > — t2 IS counted (only t3 after).
assertEquals(1L, store.count("conv1", t2))
// After last — empty.
assertEquals(0L, store.count("conv1", t3))
// Distant past — all three.
assertEquals(3L, store.count("conv1", Instant.DISTANT_PAST))
}
@Test
fun testCountAfterIsolatesByConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), t, null))
// Cursor that excludes everything — both conversations read 0.
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("conv1", future))
assertEquals(0L, store.count("conv2", future))
// Cursor that includes everything — only conv1's message matches its conversation.
assertEquals(1L, store.count("conv1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("conv2", Instant.DISTANT_PAST))
}
@Test
fun testCountAgreesWithListSize() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..10) {
store.append(
MessageRecord.UserMessage(
id = "m$i",
conversationId = "conv1",
content = listOf(Content.Text("x$i")),
createdAt = t0 + kotlin.time.Duration.parse("PT${i}S"),
context = null,
),
)
}
// count === list(..., 0, +∞).size
assertEquals(
store.list("conv1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("conv1"),
)
// count(after=t5) === list(..., after=t5, 0, +∞).size
val cutoff = t0 + kotlin.time.Duration.parse("PT5S")
assertEquals(
store.list("conv1", cutoff, 0, 1000).size.toLong(),
store.count("conv1", cutoff),
)
}
} }
@@ -0,0 +1,146 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Тесты для [KsqliteMutableConversationStore]. Автономная фикстура —
* `SQLiteConnection.memory(...)` + конструктор `KsqliteMutableConversationStore(connection)`.
* Store сам прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteMutableConversationStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteMutableConversationStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("conv-${kotlin.random.Random.nextLong()}")
store = KsqliteMutableConversationStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun rec(id: String, title: String? = null, ts: Instant = Instant.parse("2026-09-15T10:00:00Z")) =
ConversationRecord(id, title, false, ts, ts)
@Test
fun testUpsertAndGetRoundtrip() = runTest {
store.upsert(rec("c1", "test"))
assertEquals(rec("c1", "test"), store.get("c1"))
}
@Test
fun testGetReturnsNullForMissing() = runTest {
assertNull(store.get("nope"))
}
@Test
fun testDeleteRemovesAndReturnsTrue() = runTest {
store.upsert(rec("c1"))
assertTrue(store.delete("c1"))
assertNull(store.get("c1"))
assertEquals(false, store.delete("c1"))
}
@Test
fun testListSortsByUpdatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(rec("c1", ts = t0))
store.upsert(rec("c2", ts = t0))
store.upsert(rec("c3", ts = t0))
store.upsert(rec("c4", ts = t0))
store.touch("c2", t0 + Duration.parse("PT60S"))
store.touch("c3", t0 + Duration.parse("PT120S"))
store.touch("c4", t0 + Duration.parse("PT180S"))
val page = store.list(offset = 0, limit = 4)
assertEquals(listOf("c4", "c3", "c2", "c1"), page.map { it.id })
}
@Test
fun testListRespectsOffsetAndLimit() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..5) store.upsert(rec("c$i", ts = t0 + Duration.parse("PT${i}S")))
val p0 = store.list(offset = 0, limit = 2)
assertEquals(2, p0.size)
val p2 = store.list(offset = 4, limit = 2)
assertEquals(1, p2.size)
}
@Test
fun testRenameUpdatesTitleAndUpdatedAt() = runTest {
store.upsert(rec("c1"))
val newTs = store.rename("c1", "new title")
assertNotNull(newTs)
assertEquals("new title", store.get("c1")?.title)
}
@Test
fun testRenameWithNullClearsTitle() = runTest {
store.upsert(rec("c1", "old"))
store.rename("c1", null)
assertNull(store.get("c1")?.title)
}
@Test
fun testRenameReturnsNullForMissing() = runTest {
assertNull(store.rename("nope", "x"))
}
@Test
fun testTouchUpdatesUpdatedAtOnly() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
store.touch("c1", t1)
val got = store.get("c1")
assertEquals("title", got?.title)
assertEquals(t1, got?.updatedAt)
assertEquals(t0, got?.createdAt)
}
@Test
fun testMemoryFactoryAutoMigratesSchema() = runTest {
// Smoke-test: .memory() companion-фабрика должна прогнать Schema.migrate()
// автоматически. Если бы миграция не сработала — storePreparedStatement'ы
// упали бы на `prepare failed: no such table: conversation` ещё в конструкторе.
val owned = KsqliteMutableConversationStore.memory("conv-auto-${kotlin.random.Random.nextLong()}")
try {
owned.upsert(rec("c1", "hello"))
assertEquals("hello", owned.get("c1")?.title)
} finally {
owned.close()
}
}
@Test
fun testExternalConnectionConstructorAlsoMigrates() = runTest {
// Внешний конструктор `(connection)` ТОЖЕ мигрирует (Schema.migrate idempotent).
// Caller может не звать Schema.migrate перед конструктором.
val externalConn = SQLiteConnection.memory("conv-external-${kotlin.random.Random.nextLong()}")
val s = KsqliteMutableConversationStore(externalConn)
try {
s.upsert(rec("c1"))
assertNotNull(s.get("c1"))
} finally {
s.close()
externalConn.close()
}
}
}
@@ -1,26 +1,31 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* Тесты на Schema.migrate(): * Тесты на Schema.migrate() в `:journal-ksqlite`:
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION; * - fresh DB → создаются `conversation` + `message` + индексы
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на * (`idx_conv_updated`, `idx_msg_conv`);
* повторных CREATE); * - уже мигрированная БД → migrate() идемпотентен (no-op);
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores), * - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
* migrate() приводит её в боевое состояние. * migrate() приводит её в боевое состояние;
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
* делают full-scan по message.
* *
* Также проверяем что наличие индекса idx_msg_conv (conversation_id + * `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
* created_at) — обязательный hot-path для list()/cascade-delete. * в `pw.binom.agentik.reflection.ksqlite`.
*/ */
class SchemaMigrationTest { class SchemaMigrationTest {
@Test @Test
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest { fun `fresh DB gets conversation and message tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -28,8 +33,6 @@ class SchemaMigrationTest {
for (table in listOf( for (table in listOf(
Schema.TABLE_CONVERSATION, Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE, Schema.TABLE_MESSAGE,
Schema.TABLE_WORKING_MEMORY,
Schema.TABLE_REFLECTION,
)) { )) {
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()") assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
} }
@@ -37,15 +40,9 @@ class SchemaMigrationTest {
for (index in listOf( for (index in listOf(
Schema.IDX_CONV_UPDATED, Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_CONV, Schema.IDX_MSG_CONV,
Schema.IDX_WM_UNIQUE,
Schema.IDX_WM_CONV,
Schema.IDX_REFLECTION_CREATED,
Schema.IDX_REFLECTION_CONV,
)) { )) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()") assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
} }
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
} finally { } finally {
conn.close() conn.close()
} }
@@ -56,66 +53,50 @@ class SchemaMigrationTest {
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
val versionAfterFirst = readUserVersion(conn) // повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
// повторный вызов не должен ни упасть, ни изменить версию, ни
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn) Schema.migrate(conn)
assertEquals(versionAfterFirst, readUserVersion(conn)) Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
} finally { } finally {
conn.close() conn.close()
} }
} }
@Test @Test
fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest { fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
// Имитируем сценарий: существующая БД без schema, открываем через
// ksqlite и прогоняем migrate руками (тот же путь, что в
// KsqliteStores.open, но без зависимости от фабрики).
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn) Schema.migrate(conn)
// Сборка bundle через internal-конструктор — KsqliteStores primary val convStore = KsqliteMutableConversationStore(conn)
// constructor internal, тест в том же модуле и может его звать. val msgStore = KsqliteJournalStore(conn)
val stores = KsqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteMessageStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
try { try {
// bundle работает end-to-end — conversation upsert + message append + convStore.upsert(
// list. Никаких "no such table" или подобного. ConversationRecord(
stores.conversations.upsert(
pw.binom.agentik.journal.ConversationRecord(
id = "c1", title = "t", isTemporal = false, id = "c1", title = "t", isTemporal = false,
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
) )
) )
stores.messages.append( msgStore.append(
pw.binom.agentik.journal.MessageRecord.UserMessage( MessageRecord.UserMessage(
id = "m1", conversationId = "c1", id = "m1", conversationId = "c1",
content = listOf(pw.binom.agentik.journal.Content.Text("hi")), content = listOf(Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
) )
) )
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10) val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals("m1", got[0].id) assertEquals("m1", got[0].id)
} finally { } finally {
// Закрываем store'ы → они закроют свои pre-prepared statements convStore.close()
// (StmtHolder.finalize увидит isOpen == false и не полезет в msgStore.close()
// нативный sqlite3_finalize с уже-разрушенным db mutex).
stores.close()
} }
} }
@Test @Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest { fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
// Проверяем что индекс действительно покрывает обе колонки — без
// этого list()/cascade-delete будут делать full-scan по message.
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -158,16 +139,4 @@ class SchemaMigrationTest {
} }
return cols return cols
} }
private fun readUserVersion(conn: SQLiteConnection): Int {
var version = 0
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) {
version = (rs.getLong(0) ?: 0L).toInt()
}
}
}
return version
}
} }
+16 -7
View File
@@ -2,9 +2,8 @@
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class) // ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах. // — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
// //
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm // KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain, // `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
// доступные JVM target'у.
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) @file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins { plugins {
@@ -15,6 +14,14 @@ kotlin {
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
@@ -24,16 +31,18 @@ kotlin {
api(project(":context-api")) api(project(":context-api"))
api(project(":skills")) api(project(":skills"))
api(libs.litert.api) api(libs.litert.api)
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
// jvm/native variant из общего артефакта).
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
} implementation(libs.kotlinx.coroutines.test)
jvmMain.dependencies { implementation(libs.litert.tools.kotlinx.serialization)
// mu.KotlinLogging — JVM-only, для SkillMiner'а implementation(project(":memory-md"))
implementation(libs.kotlin.logging)
} }
} }
} }
@@ -29,7 +29,7 @@ class LlmReflector(
private val llm: LiteLlm, private val llm: LiteLlm,
val maxTurns: Int = 6, val maxTurns: Int = 6,
private val maxTokens: Int = 512, private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) { ) {
/** /**
@@ -0,0 +1,127 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools.memory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -8,7 +8,7 @@ import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.standalone.agent.FakeLiteLlm import pw.binom.agentik.llm.tools.FakeLiteLlm
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools.memory
import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision import pw.binom.agentik.memory.MemoryReviewDecision
+56
View File
@@ -0,0 +1,56 @@
# :mcp-bridge
Мост между протоколом [MCP](https://modelcontextprotocol.io/) (Model Context Protocol)
и `MutableAgent` из `:agent-api`. Превращает удалённые MCP-серверы в набор
тулов, доступных агенту через стандартный install-механизм.
## Что это и зачем
`McpRegistry` умеет подключаться к N MCP-серверам, опрашивать их
`tools/list` и держать в памяти именованные тулы (`NamedTool`). Но
`McpRegistry` сам по себе ничего не знает про агента — это просто реестр
плюс JSON-RPC-клиент.
`McpBridgeComponent` — адаптер: реализует `pw.binom.agentik.agent.Component`
и при `install(MutableAgent)` добавляет в `toolProviders` провайдер, который
возвращает текущий снимок тулов из реестра. При `uninstall` — снимает.
## Использование
```kotlin
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
val agent = ChatAgent(...)
.install(McpBridgeComponent(mcpRegistry))
```
После `install` MCP-тулы доступны агенту через стандартный
`toolsetDispatch.baseDispatcher` — вызываются точно так же, как и встроенные
(skill / memory / enable_toolset). Никаких особых путей.
На каждом tool-call `collectTools()` (внутри ChatAgent) перебирает все
`toolProviders` и собирает актуальный список — добавление/удаление компонента
видно немедленно, без рестарта агента.
## Структура
- `McpRegistry` — JSON-RPC клиент, поддерживает несколько MCP-серверов,
параллельный опрос `tools/list` при старте, reconnect.
- `McpBridgeComponent` — адаптер к `:agent-api`. Реализует `Component`;
держит ссылку на `McpRegistry`; на `install` пушит `McpToolProvider`
в `MutableAgent.toolProviders`, на `uninstall` снимает.
- `McpToolProvider` — внутренний `ToolProvider`, возвращает
`registry.namedTools`. Пустой по сути адаптер, нужен только чтобы дать
имя для удаления (removeAll-логика).
## Зависимости
- `:agent-api` — `MutableAgent`, `Component`, `ToolProvider`.
- `:agent-toolsets` — `NamedTool`, `LiteTool`.
- `:outbox-api` — для live-событий (connection-loss / reconnect notifications).
## НЕ включено
- HTTP/SSE транспорт к MCP-серверам (только stdio/JSON-RPC сейчас; HTTP-вариант
доделывается).
- Конвертация `McpResource` → `MemoryNote` (отдельная фича, не реализована).
- Авторизация OAuth (MCP 2025-06-15 draft) — пока нет.
+5 -3
View File
@@ -7,8 +7,8 @@
// или :agentik-tui когда те снова включатся. // или :agentik-tui когда те снова включатся.
// //
// Зависимости: // Зависимости:
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель) // - litert.api для LiteTool контракта (в v9 у LiteTool появилось поле name,
// - litert.api для LiteTool контракта // NamedTool-обёртка из :agent-toolsets больше не нужна)
// - MCP SDK (JVM-only) // - MCP SDK (JVM-only)
// - Ktor client (для StreamableHttpClientTransport) // - Ktor client (для StreamableHttpClientTransport)
// - kotlinx-serialization для парсинга конфига // - kotlinx-serialization для парсинга конфига
@@ -22,7 +22,9 @@ kotlin {
} }
dependencies { dependencies {
implementation(project(":agent-toolsets")) // :agent-api — отсюда Component / ToolProvider; McpBridgeComponent
// реализует Component и подсовывает MCP-тулы через ToolProvider.
api(project(":agent-api"))
api(libs.litert.api) api(libs.litert.api)
@@ -0,0 +1,61 @@
package pw.binom.agentik.mcp.bridge
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.ToolProvider
import pw.binom.litert.LiteTool
/**
* [Component], встраивающий [McpRegistry] в [MutableAgent] через [ToolProvider].
*
* При [install] добавляет один [ToolProvider] в `agent.toolProviders` —
* он возвращает `registry.allTools` (все MCP-тулы со всех подключённых
* серверов, с префиксом `serverName__` чтобы избежать коллизий).
*
* При [uninstall] убирает свой [ToolProvider] обратно. Повторный `uninstall`
* — no-op. **Не** закрывает [McpRegistry] — за это отвечает host
* (обычно shutdown hook в `Main.kt`).
*
* Типичное использование:
* ```
* val registry = McpRegistry.fromConfig(config.mcp)
* val agent = ChatAgent(...).install(McpBridgeComponent(registry))
* // ...
* Runtime.getRuntime().addShutdownHook(Thread { registry.close() })
* ```
*
* Пока встраивается только в `:standalone` через `:mcp-bridge` —
* `:agentik-cli` / `:agentik-tui` (когда снова включатся) получат эту же
* механику без изменений в [ChatAgent] constructor'е.
*/
class McpBridgeComponent(
registry: McpRegistry,
) : Component {
private val provider = McpToolProvider(registry)
override fun install(agent: MutableAgent) {
if (provider !in agent.toolProviders)
agent.toolProviders += provider
}
override fun uninstall(agent: MutableAgent) {
agent.toolProviders -= provider
}
/**
* [ToolProvider] поверх [McpRegistry.allTools]: всегда отдаёт
* полный список MCP-тулов вне зависимости от `conversationId`
* (per-conversation фильтрация для MCP будет, если/когда понадобится —
* сейчас MCP-тулы глобальны и для всех бесед одинаковы).
*/
private class McpToolProvider(
private val registry: McpRegistry,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> = registry.allTools
}
}
val McpRegistry.component
get() = McpBridgeComponent(this)
@@ -32,7 +32,6 @@ import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Реестр подключённых MCP-серверов. * Реестр подключённых MCP-серверов.
@@ -60,11 +59,6 @@ class McpRegistry(
connected.values.flatMap { it.tools } connected.values.flatMap { it.tools }
} }
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
val namedTools: List<NamedTool> by lazy {
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
}
/** Количество успешно подключённых серверов. */ /** Количество успешно подключённых серверов. */
val connectedServerCount: Int get() = connected.size val connectedServerCount: Int get() = connected.size
@@ -181,13 +175,13 @@ internal class McpLiteToolAdapter(
private val client: Client, private val client: Client,
) : LiteTool { ) : LiteTool {
internal val fullName: String = "${serverName}__${tool.name}" override val name: String = "${serverName}__${tool.name}"
override fun describe(): String = override fun describe(): String =
buildJsonObject { buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM. // Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor). // litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName) put("name", name)
put("description", tool.description ?: "") put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema()) put("parameters", tool.inputSchema.toJsonSchema())
}.toString() }.toString()
@@ -106,4 +106,60 @@ sealed interface Event {
@Serializable @Serializable
@SerialName("error") @SerialName("error")
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
/**
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
* kernel прибил таск). Отличается от [ToolResult]: там мы сообщаем LLM
* результат (даже если LLM его не понравился), тут — сигнал о
* внутренней ошибке **самого исполнения тула**. Используется
* background-подписчиками (например [ReflectionScheduler]-like
* компонентами) для накопления паттернов отказов. Клиенту
* показывается для transparency, но в UI особо не нужен.
*/
@Serializable
@SerialName("tool_failed")
data class ToolFailed(
override val date: Instant,
val toolCallId: String,
val toolName: String?,
val message: String,
val durationMs: Long,
) : Event
/**
* Диалог переходит в закрытое состояние ([Conversation.close] /
* [ConversationLoop.close] / `agent.deleteConversation`). Эмитится
* **до** освобождения ресурсов, чтобы background-подписчики
* (skill mining, reflection) успели сделать final pass. После
* `Closed` диалог уже удалён из `agent.getConversations()` и
* `getMessages()` отдаст только то, что осталось в журнале.
*
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
* так как открытие тривиально (id уже известен с момента
* `Agent.createConversation` → [Event.ConversationCreated]
* / [AgentEvent.Created] в outbox'е).
*/
@Serializable
@SerialName("conversation_closing")
data class ConversationClosing(
override val date: Instant,
val conversationId: String,
) : Event
/**
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
* Эмитится **до** deletion для background-подписчиков (skill mining),
* чтобы они успели сделать pass на исчезающем контенте.
*
* В отличие от [ToolFailed]/[ConversationClosing], это событие
* семантически "много контента ушло" — subscribers могут решать,
* стоит ли тратить tokens на mining ([turnsCompacted] > N).
*/
@Serializable
@SerialName("compaction_triggered")
data class CompactionTriggered(
override val date: Instant,
val conversationId: String,
val turnsCompacted: Int,
) : Event
} }
@@ -24,8 +24,9 @@ data class Reflection(
) )
/** /**
* Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу (impl в * Хранилище рефлексий. Реализации:
* `:storage-sqlite`) или in-memory (impl в `:storage-inmemory`). * - `:reflection-ksqlite` — таблица `reflection` поверх ksqlite
* - `:reflection-inmemory` — `Map<String, Reflection>` для тестов / dev
* *
* Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup) * Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup)
* или архивируются через [Curator]-подобный процесс, но не редактируются. * или архивируются через [Curator]-подобный процесс, но не редактируются.
@@ -33,8 +34,10 @@ data class Reflection(
interface ReflectionStore : AutoCloseable { interface ReflectionStore : AutoCloseable {
suspend fun insert(reflection: Reflection) suspend fun insert(reflection: Reflection)
suspend fun get(id: String): Reflection? suspend fun get(id: String): Reflection?
/** Самые свежие рефлексии (по всему агенту). */ /** Самые свежие рефлексии (по всему агенту). */
suspend fun listRecent(limit: Int = 10): List<Reflection> suspend fun listRecent(limit: Int = 10): List<Reflection>
/** Рефлексии для конкретного диалога. */ /** Рефлексии для конкретного диалога. */
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection> suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
suspend fun deleteOlderThan(cutoff: Instant) suspend fun deleteOlderThan(cutoff: Instant)
@@ -55,5 +58,5 @@ sealed interface ReflectionEvent {
* БД-схемах было видно сразу. * БД-схемах было видно сразу.
*/ */
object Ids { object Ids {
fun new(): String = "refl-${kotlin.uuid.Uuid.random()}" fun new() = "refl-${kotlin.uuid.Uuid.random()}"
} }
+36
View File
@@ -0,0 +1,36 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [ReflectionStore] поверх in-memory структур
// (`MutableMap`/`MutableList` + `Mutex`) — для тестов, dev-режима,
// embedded-сценариев (Android core, CLI, in-process кэш в клиенте)
// и как образец для своей реализации.
//
// Зависимости: только `:reflection-api` (+ `:journal-api` транзитивно
// через :reflection-api — ReflectionEvent.Created/и др. ссылаются на
// MessageRecord enum'ы). Никакого I/O — pure in-memory.
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":reflection-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.inmemory package pw.binom.agentik.reflection.inmemory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -11,7 +11,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Thread-safe List-импл [ReflectionStore]. * Thread-safe Map-импл [ReflectionStore].
* *
* `weakSpots` хранятся как List<String> в самой структуре — JSON-сериализация * `weakSpots` хранятся как List<String> в самой структуре — JSON-сериализация
* делается на уровне SQLDelight в SQLite-импле; здесь просто держим в памяти. * делается на уровне SQLDelight в SQLite-импле; здесь просто держим в памяти.
@@ -0,0 +1,95 @@
package pw.binom.agentik.reflection.inmemory
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.reflection.Reflection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.time.Instant
class InMemoryReflectionStoreTest {
private fun refl(
id: String,
conv: String? = null,
ts: Instant = Instant.parse("2026-09-15T10:00:00Z"),
score: Int = 3,
summary: String = "ok",
weak: List<String> = listOf("weak1", "weak2"),
) = Reflection(
id = id,
conversationId = conv,
createdAt = ts,
turnsAnalyzed = 5,
score = score,
summary = summary,
weakSpots = weak,
)
@Test
fun testInsertAndGetRoundtrip() = runTest {
val store = InMemoryReflectionStore()
store.insert(refl("r1"))
val got = store.get("r1")
assertNotNull(got)
assertEquals("r1", got.id)
assertEquals(listOf("weak1", "weak2"), got.weakSpots)
}
@Test
fun testGetReturnsNullForMissing() = runTest {
val store = InMemoryReflectionStore()
assertNull(store.get("nope"))
}
@Test
fun testListRecentSortedByCreatedAtDesc() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.insert(refl("r1", ts = t0))
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S")))
store.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S")))
val recent = store.listRecent(limit = 3)
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
}
@Test
fun testListForConversationFilters() = runTest {
val store = InMemoryReflectionStore()
val t = Instant.parse("2026-09-15T10:00:00Z")
store.insert(refl("r1", conv = "c-1"))
store.insert(refl("r2", conv = "c-2"))
store.insert(refl("r3", conv = "c-1"))
val forC1 = store.listForConversation("c-1", limit = 10)
assertEquals(2, forC1.size)
}
@Test
fun testDeleteOlderThanRemoves() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.insert(refl("r1", ts = t0))
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
store.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
assertNull(store.get("r1"))
assertNotNull(store.get("r2"))
}
@Test
fun testCountReturnsTotal() = runTest {
val store = InMemoryReflectionStore()
assertEquals(0, store.count())
store.insert(refl("r1"))
store.insert(refl("r2"))
assertEquals(2, store.count())
}
@Test
fun testCloseIdempotent() = runTest {
val store = InMemoryReflectionStore()
store.close()
store.close() // no-op
}
}
+38
View File
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация :reflection-api (ReflectionStore) поверх ksqlite.
//
// Минимальная — только таблица `reflection` + 2 индекса по ней.
// Остальные таблицы (`conversation`, `message`, `working_memory`) живут в
// других ksqlite-модулях; этот модуль не претендует на полную схему агента.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (ksqlite 0.1.2 не публикует native артефакты для Apple).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
api(project(":reflection-api"))
// :reflection-api ссылается на :journal-api типы в сигнатурах
// (MessageRecord и т.п. для enum'ов). Фиксируем явно чтобы
// downstream-консьюмеры не должны были декларировать ещё раз.
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.reflection.ksqlite
import pw.binom.agentik.reflection.Reflection import pw.binom.agentik.reflection.Reflection
import pw.binom.agentik.reflection.ReflectionEvent import pw.binom.agentik.reflection.ReflectionEvent
@@ -16,11 +16,42 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class KsqliteReflectionStore( /**
* ksqlite-реализация [ReflectionStore].
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteReflectionStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle).
* - `KsqliteReflectionStore(path)` — открывает файловое соединение,
* прогоняет [Schema.migrate], закрывает соединение в [close].
* - `KsqliteReflectionStore.memory(name)` — in-memory, мигрирует,
* закрывает в [close].
*/
class KsqliteReflectionStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) : ReflectionStore { ) : ReflectionStore {
constructor(path: String, clock: Clock = Clock.System) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
clock = clock,
)
constructor(connection: SQLiteConnection, clock: Clock = Clock.System) : this(
connection = connection,
ownsConnection = false,
clock = clock,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16) private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16)
@@ -155,6 +186,16 @@ class KsqliteReflectionStore(
listForConvStmt.close() listForConvStmt.close()
deleteOlderThanStmt.close() deleteOlderThanStmt.close()
countStmt.close() countStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null, clock: Clock = Clock.System): KsqliteReflectionStore =
KsqliteReflectionStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
clock = clock,
)
} }
private fun SQLiteResultSet.toDomain(): Reflection = Reflection( private fun SQLiteResultSet.toDomain(): Reflection = Reflection(
@@ -168,7 +209,7 @@ class KsqliteReflectionStore(
) )
} }
/** Hand-rolled JSON-encode/decode для List<String> — синхронизировать с :storage-sqlite. */ /** Hand-rolled JSON-encode/decode для List<String>. */
internal fun encodeStringArray(items: List<String>): String = buildString { internal fun encodeStringArray(items: List<String>): String = buildString {
append('[') append('[')
items.forEachIndexed { i, s -> items.forEachIndexed { i, s ->
@@ -0,0 +1,71 @@
package pw.binom.agentik.reflection.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:reflection-api`.
*
* Минимум — только то, что относится к `reflection` (реализация
* [KsqliteReflectionStore]). Остальные таблицы агента (`conversation`,
* `message`, `working_memory`) живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
const val TABLE_REFLECTION = "reflection"
// ───── Колонки ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_CREATED_AT = "created_at"
const val COL_TURNS_ANALYZED = "turns_analyzed"
const val COL_SCORE = "score"
const val COL_SUMMARY = "summary"
const val COL_WEAK_SPOTS_JSON = "weak_spots_json"
// ───── Индексы ─────
const val IDX_REFLECTION_CREATED = "idx_reflection_created"
const val IDX_REFLECTION_CONV = "idx_reflection_conv"
private val v1Ddl = """
CREATE TABLE IF NOT EXISTS $TABLE_REFLECTION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_TURNS_ANALYZED INTEGER NOT NULL,
$COL_SCORE INTEGER NOT NULL,
$COL_SUMMARY TEXT NOT NULL,
$COL_WEAK_SPOTS_JSON TEXT NOT NULL DEFAULT '[]'
);
""".trimIndent()
private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_REFLECTION_CREATED
ON $TABLE_REFLECTION($COL_CREATED_AT DESC);
CREATE INDEX IF NOT EXISTS $IDX_REFLECTION_CONV
ON $TABLE_REFLECTION($COL_CONVERSATION_ID, $COL_CREATED_AT DESC);
""".trimIndent()
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
*/
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.reflection.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.reflection.Reflection import pw.binom.agentik.reflection.Reflection
@@ -8,19 +8,18 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
class KsqliteReflectionStoreTest { class KsqliteReflectionStoreTest {
private lateinit var stores: KsqliteStores private lateinit var store: KsqliteReflectionStore
@BeforeTest @BeforeTest
fun setup() { fun setup() {
stores = KsqliteStores.inMemory("refl-${kotlin.random.Random.nextLong()}") store = KsqliteReflectionStore.memory("refl-${kotlin.random.Random.nextLong()}")
} }
@AfterTest @AfterTest
fun tearDown() = stores.close() fun tearDown() = store.close()
private fun refl( private fun refl(
id: String, id: String,
@@ -41,8 +40,8 @@ class KsqliteReflectionStoreTest {
@Test @Test
fun testInsertAndGetRoundtrip() = runTest { fun testInsertAndGetRoundtrip() = runTest {
stores.reflections.insert(refl("r1")) store.insert(refl("r1"))
val got = stores.reflections.get("r1") val got = store.get("r1")
assertNotNull(got) assertNotNull(got)
assertEquals("r1", got.id) assertEquals("r1", got.id)
assertEquals(listOf("weak1", "weak2"), got.weakSpots) assertEquals(listOf("weak1", "weak2"), got.weakSpots)
@@ -50,45 +49,45 @@ class KsqliteReflectionStoreTest {
@Test @Test
fun testGetReturnsNullForMissing() = runTest { fun testGetReturnsNullForMissing() = runTest {
assertNull(stores.reflections.get("nope")) assertNull(store.get("nope"))
} }
@Test @Test
fun testListRecentSortedByCreatedAtDesc() = runTest { fun testListRecentSortedByCreatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", ts = t0)) store.insert(refl("r1", ts = t0))
stores.reflections.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S"))) store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S")))
stores.reflections.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S"))) store.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S")))
val recent = stores.reflections.listRecent(limit = 3) val recent = store.listRecent(limit = 3)
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id }) assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
} }
@Test @Test
fun testListForConversationFilters() = runTest { fun testListForConversationFilters() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z") val t = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", conv = "c-1")) store.insert(refl("r1", conv = "c-1"))
stores.reflections.insert(refl("r2", conv = "c-2")) store.insert(refl("r2", conv = "c-2"))
stores.reflections.insert(refl("r3", conv = "c-1")) store.insert(refl("r3", conv = "c-1"))
val forC1 = stores.reflections.listForConversation("c-1", limit = 10) val forC1 = store.listForConversation("c-1", limit = 10)
assertEquals(2, forC1.size) assertEquals(2, forC1.size)
} }
@Test @Test
fun testDeleteOlderThanRemoves() = runTest { fun testDeleteOlderThanRemoves() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", ts = t0)) store.insert(refl("r1", ts = t0))
stores.reflections.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H"))) store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
stores.reflections.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M")) store.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
assertNull(stores.reflections.get("r1")) assertNull(store.get("r1"))
assertNotNull(stores.reflections.get("r2")) assertNotNull(store.get("r2"))
} }
@Test @Test
fun testCountReturnsTotal() = runTest { fun testCountReturnsTotal() = runTest {
assertEquals(0, stores.reflections.count()) assertEquals(0, store.count())
stores.reflections.insert(refl("r1")) store.insert(refl("r1"))
stores.reflections.insert(refl("r2")) store.insert(refl("r2"))
assertEquals(2, stores.reflections.count()) assertEquals(2, store.count())
} }
} }
@@ -0,0 +1,64 @@
package pw.binom.agentik.reflection.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Тесты на Schema.migrate() в `:reflection-ksqlite`:
* - fresh DB → создаются `reflection` + индексы;
* - уже мигрированная БД → migrate() идемпотентен (no-op).
*/
class SchemaMigrationTest {
@Test
fun `fresh DB gets reflection table and indexes`() = runTest {
val conn = SQLiteConnection.memory("refl-mig-fresh-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_REFLECTION), "table '${Schema.TABLE_REFLECTION}' should exist after migrate()")
for (index in listOf(
Schema.IDX_REFLECTION_CREATED,
Schema.IDX_REFLECTION_CONV,
)) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
}
} finally {
conn.close()
}
}
@Test
fun `migrate is idempotent on already-migrated DB`() = runTest {
val conn = SQLiteConnection.memory("refl-mig-idem-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
// повторный вызов не должен ни упасть, ни пересоздать таблицы
Schema.migrate(conn)
Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_REFLECTION))
} finally {
conn.close()
}
}
private fun tableExists(conn: SQLiteConnection, name: String): Boolean {
conn.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"
).use { stmt ->
stmt.bindText(1, name)
stmt.executeQuery().use { rs -> return rs.next() }
}
}
private fun indexExists(conn: SQLiteConnection, name: String): Boolean {
conn.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?"
).use { stmt ->
stmt.bindText(1, name)
stmt.executeQuery().use { rs -> return rs.next() }
}
}
}
+37 -24
View File
@@ -34,7 +34,7 @@ include(":standalone")
include(":llm-tools") include(":llm-tools")
// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены // Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены
// из :standalone — MCP не специфичен для standalone'а, это generic мост // из :standalone — MCP не специфичен для standalone'а, это generic мост
// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool). // между MCP-SDK и LiteTool.
include(":mcp-bridge") include(":mcp-bridge")
// Собственный протокол agentik. Пока в нём пилим, потом вынесем. // Собственный протокол agentik. Пока в нём пилим, потом вынесем.
include(":proto") include(":proto")
@@ -61,18 +61,15 @@ include(":memory-md")
// для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART // для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART
// работает через Java 11 base classes (scalar fallback). // работает через Java 11 base classes (scalar fallback).
include(":memory-vector") include(":memory-vector")
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore / // Интерфейсы хранилища и разговорной истории. Контракты распределены по
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации — // доменам: :journal-api (MutableJournalStore/MutableConversationStore),
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred). // :context-api (ContextStore), :reflection-api (ReflectionStore).
// In-memory реализация store'ов из :journal-api и :context-api. KMP, // Реализации — :journal-ksqlite/:context-ksqlite/:reflection-ksqlite (ksqlite)
// IO-зависимостей. Используется в тестах (быстрый setup, без JDBC) и будет // и :journal-inmemory/:reflection-inmemory (in-memory).
// использоваться в Android-сборке (JVector/SQLite не подходят для ART out-of-box).
//include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids) //include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids)
// и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids) // и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids)
// Модуль удалён с диска. // Модуль удалён с диска.
include(":reflection-api") include(":reflection-api")
//include(":message-log-api")
//include(":message-log-ksqlite")
// Новые API-модули трёх сущностей (canonical имена): // Новые API-модули трёх сущностей (canonical имена):
// - :journal-api — append-only audit log (бывший :message-log-api) // - :journal-api — append-only audit log (бывший :message-log-api)
// - :context-api — то что видит LLM (бывший :working-memory-api) // - :context-api — то что видит LLM (бывший :working-memory-api)
@@ -97,7 +94,6 @@ include(":journal-inmemory")
include(":memory-md-vector") include(":memory-md-vector")
//include(":event-store-in-memory") //include(":event-store-in-memory")
//include(":working-memory-api") //include(":working-memory-api")
include(":storage-inmemory")
// SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only // SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only
// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite). // KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector // Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
@@ -105,25 +101,42 @@ include(":storage-inmemory")
// Linux — собираются локально на macOS. Пока покрывает только EventStore; // Linux — собираются локально на macOS. Пока покрывает только EventStore;
// остальные store'ы мигрируют после стабилизации ksqlite и реального // остальные store'ы мигрируют после стабилизации ksqlite и реального
// использования на Android-агенте. // использования на Android-агенте.
include(":storage-ksqlite")
// ksqlite-реализация :context-api (ContextStore / working_memory table). // ksqlite-реализация :context-api (ContextStore / working_memory table).
// Минимальный модуль: только таблица `working_memory` + 2 индекса. // Минимальный модуль: только таблица `working_memory` + 2 индекса.
// Параллельно существует :storage-ksqlite/KsqliteWorkingMemoryStore.kt —
// миграция consumers'ов по чуть-чуть, отдельно.
include(":context-ksqlite") include(":context-ksqlite")
// ksqlite-реализация :journal-api (JournalStore / message table). // ksqlite-реализация :journal-api (JournalStore / message table,
// Минимальный модуль: только таблица `message` + 1 индекс // ConversationStore / conversation table). Минимальный модуль:
// `(conversation_id, created_at)`. Параллельно существует // таблицы `message` + `conversation` + индексы.
// :storage-ksqlite/KsqliteMessageStore.kt — миграция consumers'ов
// по чуть-чуть, отдельно.
include(":journal-ksqlite") include(":journal-ksqlite")
// KMP-реализация EventStore через ksqlite (https://github.com/caffeine-mgn/ksqlite). // ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector // Минимальный модуль: только таблица `reflection` + 2 индекса.
// на KMP-таргетах (JVM + linuxX64 + mingwX64). Apple targets auto-disabled на include(":reflection-ksqlite")
// Linux — собираются локально на macOS. На этом этапе покрывает только EventStore; // in-memory реализация :reflection-api (ReflectionStore). Используется в
// остальные store'ы мигрируют после стабилизации ksqlite (>=0.2) и реального // тестах, dev-режиме и embedded-сценариях. Чистый KMP (9 таргетов),
// использования на Android-агенте. // никакого I/O.
include(":reflection-inmemory")
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные // Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть // тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+. // переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
include(":agent-toolsets") include(":agent-toolsets")
// API-модуль vector-index: интерфейсы ANN-индекса, общие для любых бэкендов
// (JVector, sqlite-vec, inmemory, ...). Pure KMP, без реализаций.
include(":vector-index-api")
// JVector-реализация `MutableVectorIndexStore`. JVM-only — JVector публикуется
// только под JVM (нет KMP-таргетов). In-RAM (без persistence в v1).
include(":vector-index-jvector")
// ksqlite-реализация `MutableVectorIndexStore`. KMP (все 9 целей) — brute-force
// ANN: SELECT всех записей + cosine similarity в Kotlin. Persistence — обычная
// SQLite БД. Подходит для small-to-medium масштабов; для больших — sqlite-vec
// или JVector (выше).
include(":vector-index-ksqlite")
// Расширяемый слой поверх :proto: `MutableAgent` (наследник Agent + install/uninstall
// компонентов), `Component` (install(MutableAgent)/uninstall(MutableAgent)) и три
// capability-провайдера (system-prompt, tools, event-listener). Каждый компонент
// сам регистрирует свои capability в MutableAgent при install и снимает их при
// uninstall. KMP, все 9 целей; чистый API-слой, без реализаций.
include(":agent-api")
// Skill-подсистема как компонент: тулы skill_save/skill_delete/skill_read +
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
include(":skill-mining")
+44
View File
@@ -0,0 +1,44 @@
// Skill-подсистема как Component: тулы skill_save/skill_delete/skill_read +
// SkillMiningComponent, слушающий SkillMiningEvent и добывающий новые скилы
// через SkillMiner.
//
// KMP (9 целей — как :agent-toolsets): SkillMiningComponent в commonMain,
// SkillMiner из :llm-tools тоже KMP — нет блокеров для натива.
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :agent-api — Component / MutableAgent / ConversationAware.
api(project(":agent-api"))
// :skills — SkillStore, SkillCatalog, renderSystemPromptSection.
api(project(":skills"))
// :memory-api — typealias ConversationTurn.
api(project(":memory-api"))
// :litert-api — LiteTool / LiteLlm.
api(libs.litert.api)
// Корутины для фонового слушателя событий.
api(libs.kotlinx.coroutines.core)
// Логгер для SkillMiner.
implementation(libs.kotlin.logging)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -35,7 +35,7 @@ class SkillMiner(
private val llm: LiteLlm, private val llm: LiteLlm,
val maxTurns: Int = 30, val maxTurns: Int = 30,
private val maxTokens: Int = 1536, private val maxTokens: Int = 1536,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) { ) {
/** /**
* Mine по последним [turns] с учётом текущего каталога [existing]. * Mine по последним [turns] с учётом текущего каталога [existing].
@@ -0,0 +1,155 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.ConversationAware
import pw.binom.agentik.agent.ConversationHandle
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.skills.renderSystemPromptSection
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteTool
/**
* События, по которым SkillMiningComponent решает, что пора майнить новые скилы.
*
* Standalone-часть мэпит свой [pw.binom.agentik.outbox.OutboxStore] (через
* [pw.binom.agentik.outbox.Event.ConversationClosing] и
* [pw.binom.agentik.outbox.Event.CompactionTriggered]) на этот sealed
* interface и подаёт результат в [SkillMiningComponent.events]. Делаем так,
* чтобы модуль :skill-mining не зависел от :standalone и его внутренних типов.
*/
sealed interface SkillMiningEvent {
val conversationId: String
/** Разговор закрывается — последний шанс вытащить скилы из контекста. */
data class ConversationClosing(override val conversationId: String) : SkillMiningEvent
/** Сработал compactor — старые turn'ы ушли, mining нужен на новом контексте. */
data class ConversationCompacted(override val conversationId: String) : SkillMiningEvent
}
/**
* Полная подсистема скилов как один Component:
* 1. Тулы skill_save / skill_delete / skill_read — через [SkillToolProvider].
* 2. Секция системного промпта с актуальным каталогом — через [SkillSystemProvider].
* 3. Фоновый майнер — на [SkillMiningEvent.ConversationClosing] /
* [SkillMiningEvent.ConversationCompacted] вызывает [SkillMiner] над
* последними turn'ами разговора и пишет новые скилы в [SkillStore].
*
* Поведение майнера зависит от per-conversation state ([ConversationHandle]):
* - isTemporal=true → пропускаем (mining не имеет смысла для temp-бесед);
* - recentTurns(limit) → что майнер видит.
*
* Подключается как обычный Component: `agent.install(SkillMiningComponent(...))`.
* Дополнительно компонент реализует [ConversationAware] — [MutableAgent]
* оповещает его о создании/закрытии каждого разговора.
*/
class SkillMiningComponent(
private val store: SkillStore,
private val llm: LiteLlm,
private val events: Flow<SkillMiningEvent>,
private val scope: CoroutineScope,
) : Component, ConversationAware {
private var toolProvider: SkillToolProvider? = null
private var systemProvider: SkillSystemProvider? = null
private var minerJob: Job? = null
/** Per-conversation context для mining: id → handle. */
private val handles = mutableMapOf<String, ConversationHandle>()
private val handlesLock = Mutex()
override fun install(agent: MutableAgent) {
val tp = SkillToolProvider(store)
agent.toolProviders.add(tp)
toolProvider = tp
val sp = SkillSystemProvider(store)
agent.systemProviders.add(sp)
systemProvider = sp
// Подписка на фоновые события — единая на компонент (не на разговор).
minerJob = scope.launch {
events.collect { event ->
when (event) {
is SkillMiningEvent.ConversationClosing,
is SkillMiningEvent.ConversationCompacted -> {
mineForConversation(event.conversationId)
}
}
}
}
}
override fun uninstall(agent: MutableAgent) {
minerJob?.cancel()
minerJob = null
toolProvider?.let { agent.toolProviders.remove(it) }
systemProvider?.let { agent.systemProviders.remove(it) }
// очистка под mutex: detach-вызовы могут быть в полёте из других корутин
runBlocking { handlesLock.withLock { handles.clear() } }
}
/** [ConversationAware]: новый разговор — запоминаем handle. */
override fun attachConversation(handle: ConversationHandle) {
// attach вызывается синхронно из MutableAgent.install-flow,
// целостность важна только между attach/detach и чтением из mining-корутины
runBlocking { handlesLock.withLock { handles[handle.id] = handle } }
}
/** [ConversationAware]: разговор закрыт — забываем handle. */
override fun detachConversation(handle: ConversationHandle) {
runBlocking { handlesLock.withLock { handles.remove(handle.id) } }
}
private fun mineForConversation(conversationId: String) {
// горячий путь: одна mining-корутина читает snapshot handle и идёт работать.
// блокировка короткая, copy-on-read не нужно — handle'ы immutable в нашей модели.
val handle = runBlocking { handlesLock.withLock { handles[conversationId] } } ?: return
if (handle.isTemporal) return
scope.launch {
val miner = SkillMiner(llm)
val turns = runCatching { handle.recentTurns(miner.maxTurns) }.getOrDefault(emptyList())
if (turns.isEmpty()) return@launch
runCatching {
// ConversationTurn (agent-api) == ConversationTurn (memory-api) через typealias.
miner.mine(turns, store.catalog.skills)
}
.onSuccess { mined ->
mined.forEach { runCatching { store.upsert(it) } }
}
}
}
}
private class SkillToolProvider(
private val store: SkillStore,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> {
val catalog: SkillCatalog = store.catalog
return buildList {
add(SkillSaveTool(store))
add(SkillDeleteTool(store))
if (!catalog.isEmpty) add(SkillReadTool(catalog))
}
}
}
private class SkillSystemProvider(
private val store: SkillStore,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String =
store.catalog.renderSystemPromptSection()
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools package pw.binom.agentik.skill.mining
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -22,6 +22,8 @@ class SkillReadTool(
private val catalog: SkillCatalog, private val catalog: SkillCatalog,
) : LiteTool { ) : LiteTool {
override val name: String get() = NAME
override fun describe(): String = buildJsonObject { override fun describe(): String = buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который // Flat OpenAPI-спецификация (name/description/parameters) — формат, который
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в // принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
@@ -23,30 +23,26 @@ import pw.binom.litert.LiteTool
* } * }
* } * }
* ``` * ```
*
* ВАЖНО: параметр LiteTool-лямбды не называется `invoke` — иначе Kotlin
* резолвит `invoke(x)` рекурсивно и StackOverflow (см. MemoryToolsFactory).
*/ */
internal class SkillSaveTool( class SkillSaveTool(
private val store: SkillStore, private val store: SkillStore,
) : LiteTool { ) : LiteTool {
override val name: String = "skill_save"
override fun describe(): String = SCHEMA override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String { override fun invoke(arguments: String): String {
val parsed = parseArgs(arguments) val parsed = parseArgs(arguments)
?: return error("invalid arguments: $arguments") ?: throw IllegalStateException("invalid arguments: $arguments")
val (name, description, body) = parsed val (name, description, body) = parsed
return try { return try {
store.upsert(SkillFile(name = name, description = description, body = body)) store.upsert(SkillFile(name = name, description = description, body = body))
"""{"ok":true,"name":"${escape(name)}"}""" """{"ok":true,"name":"${escape(name)}"}"""
} catch (e: Exception) { } catch (e: Exception) {
error(e.message ?: "upsert failed") throw IllegalStateException(e.message ?: "upsert failed")
} }
} }
private fun parseArgs(arguments: String): Triple<String, String, String>? { private fun parseArgs(arguments: String): Triple<String, String, String>? {
// Минимальный парсер JSON-объекта — вытаскиваем три строковых поля.
// Полагаемся на порядок ключей в агенте: name, description, body.
val name = extractString(arguments, "name") ?: return null val name = extractString(arguments, "name") ?: return null
val description = extractString(arguments, "description") ?: return null val description = extractString(arguments, "description") ?: return null
val body = extractString(arguments, "body") ?: return null val body = extractString(arguments, "body") ?: return null
@@ -75,22 +71,23 @@ internal class SkillSaveTool(
/** /**
* Тул `skill_delete(name)` — архивирует скил. * Тул `skill_delete(name)` — архивирует скил.
*/ */
internal class SkillDeleteTool( class SkillDeleteTool(
private val store: SkillStore, private val store: SkillStore,
) : LiteTool { ) : LiteTool {
override val name: String = "skill_delete"
override fun describe(): String = SCHEMA override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String { override fun invoke(arguments: String): String {
val name = extractString(arguments, "name") val name = extractString(arguments, "name")
?: return error("missing 'name' in arguments: $arguments") ?: throw IllegalStateException("missing 'name' in arguments: $arguments")
return try { return try {
val removed = store.remove(name) val removed = store.remove(name)
if (removed) { if (removed) {
"""{"ok":true,"archived":"${escape(name)}"}""" """{"ok":true,"archived":"${escape(name)}"}"""
} else { } else {
error("skill '$name' not found") throw IllegalStateException("skill '$name' not found")
} }
} catch (e: Exception) { } catch (e: Exception) {
error(e.message ?: "delete failed") throw IllegalStateException(e.message ?: "delete failed")
} }
} }
@@ -113,8 +110,7 @@ internal class SkillDeleteTool(
/** /**
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер — * Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
* JSON простой (плоский объект с известным набором ключей), как в * JSON простой (плоский объект с известным набором ключей).
* MemoryToolsFactory.
*/ */
internal fun extractString(json: String, key: String): String? { internal fun extractString(json: String, key: String): String? {
val keyIdx = json.indexOf("\"$key\"") val keyIdx = json.indexOf("\"$key\"")
@@ -163,6 +159,3 @@ internal fun escape(s: String): String = buildString(s.length + 2) {
} }
} }
} }
/** Строит `{"error":"..."}` JSON-ответ. */
internal fun error(message: String): Nothing = throw IllegalStateException(message)
@@ -0,0 +1,127 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -1,11 +1,11 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiner import pw.binom.agentik.skill.mining.SkillMiner
class SkillMinerTest { class SkillMinerTest {
@@ -1,9 +1,9 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiningParser import pw.binom.agentik.skill.mining.SkillMiningParser
class SkillMiningParserTest { class SkillMiningParserTest {
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
@@ -19,7 +19,6 @@ class SkillReadToolTest {
@Test @Test
fun describeIsFlatOpenApiSchema() { fun describeIsFlatOpenApiSchema() {
val json = tool.describe() val json = tool.describe()
// Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
assertTrue("\"name\"" in json) assertTrue("\"name\"" in json)
assertTrue(SkillReadTool.NAME in json) assertTrue(SkillReadTool.NAME in json)
assertTrue("parameters" in json) assertTrue("parameters" in json)
+21 -6
View File
@@ -22,7 +22,7 @@ val skipVectorMemory: Boolean =
// :standalone — единственный entry-point проекта, JVM-only по факту (один // :standalone — единственный entry-point проекта, JVM-only по факту (один
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в // `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
// commonMain + commonTest — даже зависимости, которые формально JVM-only // commonMain + commonTest — даже зависимости, которые формально JVM-only
// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут // (ktor-server-cio, litert-openai, ...). KMP-плагин тут
// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`, // только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`,
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет. // `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
kotlin { kotlin {
@@ -64,25 +64,40 @@ kotlin {
} }
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту. // SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
implementation(project(":storage-ksqlite")) implementation(project(":journal-ksqlite"))
implementation(project(":context-ksqlite"))
implementation(project(":reflection-ksqlite"))
// ksqlite объявлен как `implementation` (не `api`) в каждом
// ksqlite-модуле, поэтому SqliteStores здесь использует
// SQLiteConnection напрямую — фиксируем зависимость явно.
implementation("pw.binom.db:ksqlite:0.1.2")
// Bounded-tail live event stream + per-event TTL. // Bounded-tail live event stream + per-event TTL.
implementation(project(":outbox-inmemory")) implementation(project(":outbox-inmemory"))
// :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider.
// ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.)
// добавляются через agent.install(...).
implementation(project(":agent-api"))
// LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner, // LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner,
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты). // LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
implementation(project(":agent-toolsets")) implementation(project(":agent-toolsets"))
implementation(project(":llm-tools")) implementation(project(":llm-tools"))
// Skill-подсистема как Component: тулы + SkillMiningComponent.
implementation(project(":skill-mining"))
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter). // Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
implementation(project(":mcp-bridge")) implementation(project(":mcp-bridge"))
// litert-kmp: контракт (commonMain) // litert-kmp: контракт (commonMain)
api(libs.litert.api) api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
implementation(libs.litert.tools.kotlinx.serialization)
// litert-openai: JVM-реализация // litert-openai: JVM-реализация
implementation(libs.litert.openai) api(libs.litert.openai)
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime // litert-google: встроенный LiteRT-LM движок, нужен только на runtime
runtimeOnly(libs.litert.google) api(libs.litert.google)
// Ktor server (для :server facade + a2aServer) // Ktor server (для :server facade + a2aServer)
// Используем CIO вместо Netty — он KMP (jvm + linuxX64/ios/...), нам нужен // Используем CIO вместо Netty — он KMP (jvm + linuxX64/ios/...), нам нужен
@@ -183,9 +198,9 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
// выровнено по стилю существующего деплоя). // выровнено по стилю существующего деплоя).
// //
// SSH — ключевой, пароль не спрашивает. Если упадёт — проверь // SSH — ключевой, пароль не спрашивает. Если упадёт — проверь
// `ssh root@192.168.76.166 'echo OK'` вручную. // `ssh root@192.168.76.156 'echo OK'` вручную.
val agentikDeployHost: String = val agentikDeployHost: String =
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.166" (project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.156"
val agentikDeployPath: String = val agentikDeployPath: String =
(project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar" (project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar"
@@ -13,7 +13,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.standalone.agent.ChatConversation import pw.binom.agentik.standalone.agent.ChatConversation
import pw.binom.agentik.llm.tools.LlmReflector import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.standalone.agent.memory.Curator import pw.binom.agentik.standalone.agent.memory.Curator
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.context.ContextStore import pw.binom.agentik.context.ContextStore
@@ -30,9 +30,9 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.ModelDownloader import pw.binom.agentik.standalone.llm.ModelDownloader
import pw.binom.agentik.mcp.bridge.McpRegistry import pw.binom.agentik.mcp.bridge.McpRegistry
import pw.binom.agentik.storage.ksqlite.KsqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import java.io.File import java.io.File
import pw.binom.agentik.llm.tools.SkillMiner import pw.binom.agentik.skill.mining.SkillMiner
/** /**
* standalone-контейнер agentik: * standalone-контейнер agentik:
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080) * - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
@@ -199,7 +199,7 @@ private fun runServer() {
} }
val llm = config.llm.createLlm() val llm = config.llm.createLlm()
val sqliteStores = KsqliteStores.open(path = config.agent.dbPath) val sqliteStores = SqliteStores.open(path = config.agent.dbPath)
val mcpRegistry = McpRegistry.fromConfig(config.mcp) val mcpRegistry = McpRegistry.fromConfig(config.mcp)
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём // Хранилище скилов: если skillsDir задан, читаем каталог + создаём
@@ -214,9 +214,9 @@ private fun runServer() {
// Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы, // Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы,
// которые модель забыла сохранить через `skill_save`. Работает только когда // которые модель забыла сохранить через `skill_save`. Работает только когда
// есть куда писать (skillStore) и интервал > 0. // есть куда писать (skillStore) и интервал > 0.
val skillMiner: pw.binom.agentik.llm.tools.SkillMiner? = val skillMiner: pw.binom.agentik.skill.mining.SkillMiner? =
if (skillStore != null && config.skillMining.interval > 0) { if (skillStore != null && config.skillMining.interval > 0) {
pw.binom.agentik.llm.tools.SkillMiner( pw.binom.agentik.skill.mining.SkillMiner(
llm = llm, llm = llm,
maxTurns = config.skillMining.maxTurns, maxTurns = config.skillMining.maxTurns,
) )
@@ -369,7 +369,6 @@ private fun runServer() {
reflectionStore = sqliteStores.reflections, reflectionStore = sqliteStores.reflections,
llm = llm, llm = llm,
llmConfig = config.llm, llmConfig = config.llm,
tools = mcpRegistry.namedTools,
skills = skills, skills = skills,
skillStore = skillStore, skillStore = skillStore,
memoryStore = memorySystem?.store, memoryStore = memorySystem?.store,
@@ -381,8 +380,7 @@ private fun runServer() {
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
recentReflections = recentReflections, recentReflections = recentReflections,
reflector = reflector, reflector = reflector,
skillMiner = skillMiner, ).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
)
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а, // Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
// чтобы debug-эндпоинты могли его триггерить вручную. // чтобы debug-эндпоинты могли его триггерить вручную.
@@ -1,84 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Internal event bus для background work — separate from [ConversationEvents]
* (который это SSE-event stream для клиента).
*
* Background work fires на **structural events**, не на interval-polling:
* - **Review** — триггерится в CompactionCoordinator ПРЯМО ПЕРЕД удалением ходов
* из working memory (last chance вытащить факты). Это уже было сделано
* через `reviewer.reviewPreCompaction()` — оставляем как есть.
* - **Reflection** — на `ConversationLifecycleEvent.Closing` (финальная
* рефлексия перед закрытием) ИЛИ накопление N tool-failures в окне
* (что-то идёт не так).
* - **Skill mining** — на `CompactionEvent.Triggered` если `turnsToDelete > N`
* (есть контент для минера) ИЛИ на `ConversationLifecycleEvent.Closing`.
*
* Subscribers (BackgroundScheduler) решают, что делать. НЕ текстовая
* инспекция, НЕ regex — только структурные события с явным семантическим
* смыслом. См. STANDALONE-REVIEW раздел "event-driven background".
*/
/** Эмитится из [ToolDispatcher] после каждого `runToolAndPersist` (success/failure). */
sealed interface ToolCallEvent {
val toolName: String
data class Succeeded(
override val toolName: String,
val durationMs: Long,
) : ToolCallEvent
data class Failed(
override val toolName: String,
val error: String,
) : ToolCallEvent
}
/** Эмитится из [CompactionCoordinator] ПЕРЕД `workingMemory.compact(...)`. */
sealed interface CompactionEvent {
/**
* Compaction сейчас удалит N ходов из working memory. Background work
* имеет последний шанс вытащить оттуда данные.
*/
data class Triggered(
val turnsToDelete: Int,
val conversationId: String,
) : CompactionEvent
}
/** Эмитится из `ConversationLoop.close()` сразу ПЕРЕД `agentScope.cancel()`. */
sealed interface ConversationLifecycleEvent {
data class Closing(val conversationId: String) : ConversationLifecycleEvent
}
internal class BackgroundEventBus {
private val _toolCallEvents = MutableSharedFlow<ToolCallEvent>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val toolCallEvents: SharedFlow<ToolCallEvent> get() = _toolCallEvents.asSharedFlow()
private val _compactionEvents = MutableSharedFlow<CompactionEvent>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val compactionEvents: SharedFlow<CompactionEvent> get() = _compactionEvents.asSharedFlow()
private val _lifecycleEvents = MutableSharedFlow<ConversationLifecycleEvent>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val lifecycleEvents: SharedFlow<ConversationLifecycleEvent> get() = _lifecycleEvents.asSharedFlow()
fun tryEmit(event: ToolCallEvent): Boolean = _toolCallEvents.tryEmit(event)
fun tryEmit(event: CompactionEvent): Boolean = _compactionEvents.tryEmit(event)
fun tryEmit(event: ConversationLifecycleEvent): Boolean = _lifecycleEvents.tryEmit(event)
}
@@ -1,25 +1,41 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.standalone.agent
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.ConversationAware
import pw.binom.agentik.agent.ConversationHandle
import pw.binom.agentik.agent.ConversationTurn
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.llm.tools.ContextCompactor import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.LlmReflector import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.memory.MemoryPrefetcher import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystemGuidance import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.proto.Agent as ProtoAgent import pw.binom.agentik.proto.Agent as ProtoAgent
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.Event as OutboxEvent
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.MutableOutboxStore import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Conversation as ProtoConversation import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.renderSystemPromptSection import pw.binom.agentik.standalone.agent.memory.MemoryComponent
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory import pw.binom.agentik.standalone.agent.soul.SoulComponent
import pw.binom.agentik.skill.mining.SkillMiningComponent
import pw.binom.agentik.skill.mining.SkillMiningEvent
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.ConversationStore
@@ -30,16 +46,17 @@ import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.context.ContextStore import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.toolsets.DisableToolsetTool import pw.binom.agentik.toolsets.DisableToolsetTool
import pw.binom.agentik.toolsets.EnableToolsetTool import pw.binom.agentik.toolsets.EnableToolsetTool
import pw.binom.agentik.toolsets.NamedTool import pw.binom.litert.LiteTool
import pw.binom.agentik.toolsets.SystemPromptToolsetSection import pw.binom.agentik.toolsets.SystemPromptToolsetSection
import pw.binom.agentik.toolsets.ToolsetComponent
import pw.binom.agentik.toolsets.ToolsetContribution import pw.binom.agentik.toolsets.ToolsetContribution
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.agentik.toolsets.ToolsetRegistry import pw.binom.agentik.toolsets.ToolsetRegistry
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
/** /**
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и * Stateful [MutableAgent] (= [ProtoAgent] + install/uninstall/component extension)
* [LiteLlm] (универсальный LLM-контракт). * на базе SQLite (история + working memory) и [LiteLlm] (универсальный LLM-контракт).
* *
* Создаёт [ChatConversation] — те самые stateful диалоги, которые * Создаёт [ChatConversation] — те самые stateful диалоги, которые
* хранят свой собственный LiteConversation в ОЗУ (для скорости) и * хранят свой собственный LiteConversation в ОЗУ (для скорости) и
@@ -47,6 +64,13 @@ import pw.binom.litert.LiteLlm
* *
* Один [LiteLlm] шарится между всеми беседами агента. * Один [LiteLlm] шарится между всеми беседами агента.
* *
* **External тулы (MCP, etc.)** приходят не через constructor, а через
* [Component.install]: типичный путь — `McpBridgeComponent` из `:mcp-bridge`
* добавляет [ToolProvider] в [toolProviders] при [install]. На каждом
* `send()` или создании беседы агент вызывает [collectTools] чтобы
* собрать свежий список тулов (= внешние через [toolProviders] + встроенные).
* Это позволяет добавлять/убирать MCP-серверы в runtime без рестарта.
*
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в * Если задан [skills], их каталог (имя + краткое описание) подмешивается в
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для * системный промпт, а в набор тулов добавляется встроенный `read_skill` для
* загрузки полного текста навыка по требованию. * загрузки полного текста навыка по требованию.
@@ -57,7 +81,7 @@ import pw.binom.litert.LiteLlm
* `ChatConversation` настраивается prefetch (контекст в начале user-сообщения) * `ChatConversation` настраивается prefetch (контекст в начале user-сообщения)
* и post-turn reviewer (извлечение фактов после каждого хода). * и post-turn reviewer (извлечение фактов после каждого хода).
*/ */
class ChatAgent( internal class ChatAgent(
override val id: String, override val id: String,
private val mutableConversationStore: MutableConversationStore, private val mutableConversationStore: MutableConversationStore,
private val messageStore: MutableJournalStore, private val messageStore: MutableJournalStore,
@@ -65,7 +89,6 @@ class ChatAgent(
private val reflectionStore: ReflectionStore, private val reflectionStore: ReflectionStore,
private val llm: LiteLlm, private val llm: LiteLlm,
private val llmConfig: LlmConfig, private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = emptyList(),
private val skills: SkillCatalog = SkillCatalog.EMPTY, private val skills: SkillCatalog = SkillCatalog.EMPTY,
/** /**
* Хранилище скилов для self-improvement (запись/архивирование). Если * Хранилище скилов для self-improvement (запись/архивирование). Если
@@ -76,6 +99,7 @@ class ChatAgent(
private val skillStore: pw.binom.agentik.skills.SkillStore? = null, 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,
internal val agentScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
private val memoryReviewer: MemoryReviewer? = null, private val memoryReviewer: MemoryReviewer? = null,
/** /**
* Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало * Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
@@ -105,94 +129,61 @@ class ChatAgent(
* Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен. * Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен.
*/ */
private val reflector: LlmReflector? = null, private val reflector: LlmReflector? = null,
/**
* Фоновый минер скилов: LLM-вызов, который запускается на compaction
* (`turnsToDelete > 10`) или при closing conversation. `null` = mining выключен.
* Сетка безопасности, если модель забыла вызвать `skill_save` сама.
*/
private val skillMiner: SkillMiner? = null,
/** /**
* Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает * Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает
* о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются, * о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются,
* секция в system prompt НЕ добавляется (полная невидимость per A1-α). * секция в system prompt НЕ добавляется (полная невидимость per A1-α).
*/ */
private val toolsets: List<ToolsetContribution> = emptyList(), private val toolsets: List<ToolsetContribution> = emptyList(),
) : ProtoAgent, AutoCloseable { ) : MutableAgent, AutoCloseable {
/** /**
* Test-only: регистрирует дополнительный tool в общий [toolsByName] ПОСЛЕ * Провайдеры внешних/динамических тулов (MCP, etc.). Заполняются
* создания ChatAgent. Используется в тестах `interrupt mid-tool` для * компонентами через [install]. На каждом [collectTools] агент
* симуляции долгого tool-вызова, который можно прервать через interrupt(). * спрашивает у всех провайдеров их список тулов.
* В production этот API НЕ используется — тулы статичны через конструктор. *
* Изменение этого списка (через [install]/[uninstall]) сразу же отражается
* на tool-dispatch следующего turn'а любой беседы.
*/ */
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) { override val toolProviders: MutableList<ToolProvider> = mutableListOf()
toolsByName[name] = NamedTool(name = name, tool = tool)
}
/** /**
* Реестр активных тулсетов — один на агента (per-agent state). * Провайдеры секций системного промпта. Заполняются компонентами
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations. * через [install]. На каждом turn'е агент собирает секции через
* [SystemPromptProvider.getSection].
*/
override val systemProviders: MutableList<SystemPromptProvider> = mutableListOf()
/**
* Установленные компоненты. В [close] проходим в обратном порядке
* и дёргаем [Component.uninstall], чтобы каждый успел закрыть свои ресурсы.
*/
private val components: MutableList<Component> = mutableListOf()
/**
* Реестр активных тулсетов — один на агента (per-agent state). Раньше
* жил прямо в ChatAgent, теперь поднимается через [ToolsetComponent]
* (см. init-блок ниже). Доступ к реестру — через [toolsetDispatch].
*/ */
private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets) private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets)
/** Список активных тулов (включая enable/disable если есть тулсеты). */
private val enabledToolsetTools: List<NamedTool> = if (toolsets.isNotEmpty()) {
listOf(
NamedTool(EnableToolsetTool.NAME, EnableToolsetTool(toolsetRegistry).tool),
NamedTool(DisableToolsetTool.NAME, DisableToolsetTool(toolsetRegistry).tool),
)
} else emptyList()
/** /**
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти * Системный промпт: (soul, если задан) → база → секция навыков → секция памяти
* → секция self-reflection (слабые места) → секция тулсетов (если они заданы). * → секция self-reflection (слабые места). Секция тулсетов теперь приходит
* Именно он сидируется в working memory и передаётся в [ChatConversation]. * динамически от [ToolsetComponent] через [systemProviders].
*/ */
private val systemPrompt: String = buildSystemPrompt( private val baseSystemPrompt: String = buildSystemPrompt(
base = llmConfig.systemPrompt, base = llmConfig.systemPrompt,
skills = skills,
memoryEnabled = memoryStore != null, memoryEnabled = memoryStore != null,
soulBody = soulBody,
reflections = recentReflections, reflections = recentReflections,
toolsetSection = if (toolsets.isNotEmpty()) {
SystemPromptToolsetSection.render(
active = emptyList(), // все по умолчанию неактивны
inactive = toolsets,
)
} else null,
) )
/** /**
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные: * Тестовый инжектор: позволяет `ChatAgentTest` подсунуть тул, который
* `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть * скриптованная LLM будет вызывать. В production-конфигурации всегда пуст —
* [skillStore]), `memory_*` (если подключена память), * инструменты приходят через [toolProviders] (компоненты, [install]).
* `enable_toolset`/`disable_toolset` (если заданы [toolsets]).
* MCP-тулы префиксованы `server__`, так что коллизий нет.
*/ */
private val allTools: List<NamedTool> = buildList { private val testTools: MutableList<LiteTool> = mutableListOf()
addAll(tools)
addAll(enabledToolsetTools)
if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills)))
if (skillStore != null) addAll(SkillToolsFactory.create(skillStore))
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
}
private val toolsByName: MutableMap<String, NamedTool> = allTools.associateBy { it.name }.toMutableMap()
/**
* Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда
* [toolsets] пустой (тогда работает как passthrough через baseDispatcher).
* Это позволяет ChatConversation.runTurn всегда идти через один путь,
* без ветвления «с тулсетами / без».
*/
private val toolsetDispatch: ToolsetDispatchPolicy = ToolsetDispatchPolicy(
registry = toolsetRegistry,
baseDispatcher = { name, args ->
val t = toolsByName[name]
?: error("unknown tool: $name")
t.tool.invoke(args)
},
)
/** /**
* Единый канал всех событий агента — bounded tail с auto-TTL. * Единый канал всех событий агента — bounded tail с auto-TTL.
@@ -212,6 +203,185 @@ class ChatAgent(
ttl = null, ttl = null,
) )
/**
* Собирает **актуальный** список тулов для диспетчеризации:
* внешние из [toolProviders] + testTools + встроенные (read_skill,
* skill_save, memory_*, toolset enable/disable).
* Вызывается **на каждый tool-call** через [toolsetDispatch] (поэтому без
* кэша — добавление компонента сразу видно в dispatch).
*
* Дедупликация не делается — если два провайдера вернут тул с одинаковым
* именем, второй в lookup'е победит первый. Это намеренно оставлено на
* ответственности компонентов (в типичной конфигурации каждый компонент
* отвечает за свой namespace-префикс, и коллизий нет).
*/
internal fun collectTools(): List<LiteTool> = buildList {
toolProviders.forEach { addAll(it.getTools(conversationId = "")) }
addAll(testTools)
// Skill-* тулы приходят через SkillComponent → ToolProvider.
// memory_* тулы приходят через MemoryComponent → ToolProvider.
}
/**
* Тестовый хук: регистрирует [tool] под именем [name] в общем пуле.
* Используется только в `ChatAgentTest`. В production-конфигурации тулы
* приходят через [install] компонентов с [ToolProvider].
*/
internal fun addTestTool(tool: LiteTool) {
testTools.add(tool)
}
/**
* Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда
* [toolsets] пустой (тогда работает как passthrough через baseDispatcher).
* Это позволяет ChatConversation.runTurn всегда идти через один путь,
* без ветвления «с тулсетами / без».
*
* `baseDispatcher` лениво пересобирает список тулов через [collectTools]
* — это значит добавление/удаление компонента в runtime сразу же видно
* в tool-dispatch следующего turn'а.
*/
private val toolsetDispatch: ToolsetDispatchPolicy = ToolsetDispatchPolicy(
registry = toolsetRegistry,
baseDispatcher = { name, args ->
val t = collectTools().firstOrNull { it.name == name }
?: error("unknown tool: $name")
t.invoke(args)
},
)
init {
// Поднимаем механику toolsets как обычный Component — теперь тулсеты
// могут быть добавлены/убраны в runtime через тот же [install] API.
if (toolsets.isNotEmpty()) install(ToolsetComponent(toolsets))
// Память: тулы + system-provider — отдельным компонентом.
if (memoryStore != null) install(MemoryComponent(memoryStore))
// Скиллы: тулы + system-provider + фоновый mining.
if (skillStore != null) install(
SkillMiningComponent(
store = skillStore,
llm = llm,
events = skillMiningEvents(),
scope = agentScope,
)
)
// Душа: первая секция системного промпта.
if (!soulBody.isNullOrBlank()) install(SoulComponent(soulBody))
}
// Системный промпт для беседы — статичная база [baseSystemPrompt] плюс
// динамические секции из [systemProviders] на момент turn'а.
// В ConversationLoop передаётся через [systemPromptResolver] лямбду,
// которая дёргается на каждом `sendStreamContents` — LiteRT v12 setter
// сам делает no-op, если значение эквивалентно.
private val systemPrompt: String = baseSystemPrompt
/**
* Подписка на skill-mining через outbox (единый канал всех событий):
* - [OutboxEvent.ConversationClosing] → [SkillMiningEvent.ConversationClosing]
* - [OutboxEvent.CompactionTriggered] → [SkillMiningEvent.ConversationCompacted]
*
* Оба маппатся в один [Flow], `SkillMiningComponent` сам решает что делать.
*/
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
eventStore.events(after = null)
.filterIsInstance<CommonEvent.Conversation>()
.mapNotNull { ce ->
when (val e = ce.event) {
is OutboxEvent.ConversationClosing -> SkillMiningEvent.ConversationClosing(ce.conversationId)
is OutboxEvent.CompactionTriggered -> SkillMiningEvent.ConversationCompacted(ce.conversationId)
else -> null
}
}
internal fun buildRuntimeSystemPrompt(): String {
// Секции от systemProviders идут ПЕРЕД базой — это позволяет компонентам
// типа SoulComponent поставить себя во главу угла. Если нужна секция
// после базы — у компонента должен быть какой-то признак "prepend: false"
// (сейчас все systemProviders считаются prepend-only).
if (systemProviders.isEmpty()) return systemPrompt
val sections = systemProviders.mapNotNull { p ->
val section = p.getSection(conversationId = "")
section.takeIf { it.isNotBlank() }
}
if (sections.isEmpty()) return systemPrompt
return sections.joinToString("\n\n") + "\n\n" + systemPrompt.trimEnd()
}
/** Прокидывает [component] в [MutableAgent] и запоминает для [close]. */
override fun install(component: Component): MutableAgent {
component.install(this)
components.add(component)
return this
}
/** Снимает [component]. Идемпотентно — повторный uninstall безопасен. */
override fun uninstall(component: Component): MutableAgent {
component.uninstall(this)
components.remove(component)
return this
}
/**
* Оповещает [ConversationAware] компоненты о новом разговоре. Вызывается
* из [ChatConversation] сразу после конструктора — компонент может
* подписаться на events и запустить фоновые задачи.
*/
override fun attachConversation(handle: ConversationHandle) {
components.filterIsInstance<ConversationAware>().forEach { it.attachConversation(handle) }
}
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
override fun detachConversation(handle: ConversationHandle) {
components.filterIsInstance<ConversationAware>().forEach { it.detachConversation(handle) }
}
/**
* Проекция ChatConversation → ConversationHandle. Даём per-conversation
* компонентам только минимальный view (id / isTemporal / recentTurns),
* чтобы они не лезли в чужие обязанности.
*
* [recentTurns] берёт последние [limit] реплик из working memory —
* это всё, что нужно [pw.binom.agentik.skill.mining.SkillMiningComponent] /
* [LlmReflector] для добычи знаний.
*/
private fun ChatConversation.asHandle(): ConversationHandle = object : ConversationHandle {
override val id: String get() = this@asHandle.id
override val isTemporal: Boolean get() = this@asHandle.isTemporal
override suspend fun recentTurns(limit: Int): List<ConversationTurn> {
val rows = workingMemoryStore.list(this@asHandle.id)
val filtered = rows.map { it.entry }.filter {
it is pw.binom.agentik.context.WorkingMemoryEntry.User ||
it is pw.binom.agentik.context.WorkingMemoryEntry.Assistant
}
// Схлопываем User/Assistant в пары user+assistant.
// Если последним идёт User без пары — assistantMessage пустой.
val pairs = mutableListOf<ConversationTurn>()
var pendingUser: String? = null
for (entry in filtered.takeLast(limit * 2)) {
val text = when (entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
.joinToString("\n") { it.body }
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
.joinToString("\n") { it.body }
else -> continue
}
when (entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = text
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> {
pairs += ConversationTurn(userMessage = pendingUser.orEmpty(), assistantMessage = text)
pendingUser = null
}
}
}
pendingUser?.let { pairs += ConversationTurn(userMessage = it, assistantMessage = "") }
return pairs.takeLast(limit)
}
override fun close() = Unit
}
/** /**
* Read-only view of [messageStore] для HTTP-фасада в `:server` * Read-only view of [messageStore] для HTTP-фасада в `:server`
* (`Route.agentikAgent` → `/journal/...` endpoint'ы). * (`Route.agentikAgent` → `/journal/...` endpoint'ы).
@@ -278,7 +448,9 @@ class ChatAgent(
eventStore = eventStore, eventStore = eventStore,
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
tools = allTools, systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch, toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher, memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
@@ -287,12 +459,14 @@ class ChatAgent(
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflector = reflector, reflector = reflector,
skillMiner = skillMiner,
skillMiningStore = skillStore,
) )
runBlocking { runBlocking {
liveLock.withLock { live[conv.id] = conv } liveLock.withLock { live[conv.id] = conv }
} }
// Оповещаем ConversationAware компоненты (например, SkillMiningComponent)
// о новом разговоре — они могут подписаться на events и запустить
// фоновые задачи.
attachConversation(conv.asHandle())
runBlocking { runBlocking {
eventStore.append( eventStore.append(
CommonEvent.Agent( CommonEvent.Agent(
@@ -309,14 +483,21 @@ class ChatAgent(
val rec = mutableConversationStore.get(id) ?: return null val rec = mutableConversationStore.get(id) ?: return null
return newConversation(rec).also { return newConversation(rec).also {
liveLock.withLock { live[id] = it } liveLock.withLock { live[id] = it }
attachConversation(it.asHandle())
} }
} }
override suspend fun deleteConversation(id: String): Boolean { override suspend fun deleteConversation(id: String): Boolean {
val conv = liveLock.withLock { live.remove(id) } val conv = liveLock.withLock { live.remove(id) }
conv?.let { detachConversation(it.asHandle()) }
conv?.close() conv?.close()
val ok = mutableConversationStore.delete(id) val ok = mutableConversationStore.delete(id)
if (ok) { if (ok) {
// Каскад — здесь, потому что владелец lifecycle диалога
// (ChatAgent) имеет доступ ко всем трём store'ам, а каждый
// отдельный store должен знать только про свою таблицу.
messageStore.clear(id)
workingMemoryStore.clear(id)
val event = AgentEvent.Deleted(date = now(), id = id) val event = AgentEvent.Deleted(date = now(), id = id)
eventStore.append(CommonEvent.Agent(date = now(), event = event)) eventStore.append(CommonEvent.Agent(date = now(), event = event))
} }
@@ -339,7 +520,9 @@ class ChatAgent(
eventStore = eventStore, eventStore = eventStore,
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
tools = allTools, systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch, toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher, memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
@@ -348,17 +531,23 @@ class ChatAgent(
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflector = reflector, reflector = reflector,
skillMiner = skillMiner,
skillMiningStore = skillStore,
) )
override fun close() { override fun close() {
runBlocking { runBlocking {
liveLock.withLock { liveLock.withLock {
live.values.forEach { it.close() } live.values.forEach { it.close() }
live.values.forEach { detachConversation(it.asHandle()) }
live.clear() live.clear()
} }
} }
// Снимаем компоненты в обратном порядке, чтобы каждый успел закрыть свои ресурсы.
// Копия `components` нужна на случай если uninstall модифицирует список.
val snapshot = components.toList()
for (i in snapshot.indices.reversed()) {
runCatching { snapshot[i].uninstall(this) }
}
components.clear()
runCatching { llm.close() } runCatching { llm.close() }
} }
@@ -375,28 +564,17 @@ class ChatAgent(
*/ */
internal fun buildSystemPrompt( internal fun buildSystemPrompt(
base: String, base: String,
skills: SkillCatalog,
memoryEnabled: Boolean, memoryEnabled: Boolean,
soulBody: String? = null,
reflections: List<Reflection> = emptyList(), reflections: List<Reflection> = emptyList(),
toolsetSection: String? = null,
): String { ): String {
val trimmedBase = base.trimEnd() val trimmedBase = base.trimEnd()
val skillsSection = skills.renderSystemPromptSection() val withMemory = if (memoryEnabled) trimmedBase + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else trimmedBase
val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection
val withMemory = if (memoryEnabled) withSkills + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else withSkills
val withReflections = if (reflections.isNotEmpty()) { val withReflections = if (reflections.isNotEmpty()) {
withMemory + "\n\n" + renderReflectionsSection(reflections) withMemory + "\n\n" + renderReflectionsSection(reflections)
} else { } else {
withMemory withMemory
} }
val withToolsets = if (!toolsetSection.isNullOrBlank()) { return withReflections
withReflections + "\n\n" + toolsetSection
} else {
withReflections
}
val trimmedSoul = soulBody?.trim()
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withToolsets else withToolsets
} }
/** /**
@@ -3,7 +3,7 @@ package pw.binom.agentik.standalone.agent
/** /**
* Историческое имя класса. До 2025-Q4 разбиения god-class а на компоненты * Историческое имя класса. До 2025-Q4 разбиения god-class а на компоненты
* (`ConversationState`, `ConversationEvents`, `ContextBuilder`, * (`ConversationState`, `ConversationEvents`, `ContextBuilder`,
* `CompactionCoordinator`, `ToolDispatcher`, `BackgroundScheduler`) вся * `CompactionCoordinator`, `ToolDispatcher`, `ReflectionScheduler`) вся
* логика жила в `class ChatConversation` здесь, ~1400 строк. * логика жила в `class ChatConversation` здесь, ~1400 строк.
* *
* После рефактора — реализация переехала в [ConversationLoop] (этот же пакет). * После рефактора — реализация переехала в [ConversationLoop] (этот же пакет).
@@ -18,6 +18,7 @@ import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole import pw.binom.litert.LiteRole
import pw.binom.agentik.llm.tools.ContextCompactor import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.SummaryTurn import pw.binom.agentik.llm.tools.SummaryTurn
import kotlin.time.Instant
internal class CompactionCoordinator( internal class CompactionCoordinator(
private val state: ConversationState, private val state: ConversationState,
@@ -28,13 +29,14 @@ internal class CompactionCoordinator(
private val memoryStoreForReview: MemoryStore?, private val memoryStoreForReview: MemoryStore?,
private val workingMemory: ContextStore, private val workingMemory: ContextStore,
private val liteLlm: LiteLlm, private val liteLlm: LiteLlm,
private val events: ConversationEvents,
private val now: () -> Instant,
private val systemPrompt: String, private val systemPrompt: String,
private val backgroundEvents: BackgroundEventBus,
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
private val toolsCharsCached: Int by lazy(LazyThreadSafetyMode.PUBLICATION) { private val toolsCharsCached: Int by lazy(LazyThreadSafetyMode.PUBLICATION) {
state.tools.sumOf { it.tool.describe().length } state.tools.sumOf { it.describe().length }
} }
suspend fun compactPreTurnIfNeeded(): Boolean = compactPreTurn(force = false) suspend fun compactPreTurnIfNeeded(): Boolean = compactPreTurn(force = false)
@@ -66,11 +68,7 @@ internal class CompactionCoordinator(
} }
if (toCompact.isEmpty()) return false if (toCompact.isEmpty()) return false
// Emit CompactionEvent BEFORE deletion. BackgroundScheduler может trigger'ить events.tryEmit(pw.binom.agentik.outbox.Event.CompactionTriggered(date = now(), conversationId = state.id, turnsCompacted = toCompact.size))
// skill mining на основе turnsToDelete (есть контент — есть что майнить).
backgroundEvents.tryEmit(
CompactionEvent.Triggered(turnsToDelete = toCompact.size, conversationId = state.id),
)
val turns = toCompact.mapNotNull { row -> val turns = toCompact.mapNotNull { row ->
when (val e = row.entry) { when (val e = row.entry) {
@@ -147,7 +145,14 @@ internal class CompactionCoordinator(
val dropFrom = toCompact.first().orderIdx val dropFrom = toCompact.first().orderIdx
workingMemory.compact(dropFromOrderIdx = dropFrom, conversationId = state.id, summaryText = summaryText) workingMemory.compact(dropFromOrderIdx = dropFrom, conversationId = state.id, summaryText = summaryText)
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } } // После компакта пересборка LiteConversation не нужна: litert ≥13
// умеет replaceHistory() — синхронизируем движковое состояние с актуальной
// working memory, не теряя LiteRT-LM session. На OpenAI replaceHistory тоже
// лучше (stateless — просто подмена списка).
state.liteConvRef.get()?.let { lc ->
val newHistory = buildHistoryLiteMessages(excludeUserSourceId = null)
runCatching { lc.replaceHistory(newHistory) }
}
val after = estimateTokens( val after = estimateTokens(
systemText = systemText, systemText = systemText,
@@ -189,7 +194,29 @@ internal class CompactionCoordinator(
): LiteConversation { ): LiteConversation {
state.liteConvRef.get()?.let { return it } state.liteConvRef.get()?.let { return it }
val pastTurns: List<LiteMessage> = if (state.isTemporal) emptyList() else workingMemory.list(state.id) val pastTurns = buildHistoryLiteMessages(excludeUserSourceId)
val capped = if (pastTurns.size > MAX_SEEDED_MESSAGES) pastTurns.takeLast(MAX_SEEDED_MESSAGES) else pastTurns
val config = LiteConversationConfig(
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
initialMessages = capped,
tools = state.tools,
)
return liteLlm.createConversation(config).also { state.liteConvRef.set(it) }
}
/**
* Перевод [WorkingMemoryRow] в формат [LiteMessage] для LiteConversation.
* Используется и при первом create, и при [LiteConversation.replaceHistory]
* после компакта (см. [compactPreTurn]).
*/
private suspend fun buildHistoryLiteMessages(
excludeUserSourceId: String?,
): List<LiteMessage> {
if (state.isTemporal) return emptyList()
return workingMemory.list(state.id)
.filter { row -> .filter { row ->
val isRelevant = row.entry is WorkingMemoryEntry.User val isRelevant = row.entry is WorkingMemoryEntry.User
|| row.entry is WorkingMemoryEntry.Assistant || row.entry is WorkingMemoryEntry.Assistant
@@ -199,7 +226,7 @@ internal class CompactionCoordinator(
} }
.mapNotNull { row -> .mapNotNull { row ->
val e: WorkingMemoryEntry = row.entry val e: WorkingMemoryEntry = row.entry
val msg: LiteMessage? = when (e) { when (e) {
is WorkingMemoryEntry.User -> LiteMessage( is WorkingMemoryEntry.User -> LiteMessage(
LiteRole.USER, LiteRole.USER,
applyContextPrefix(e.content.toLiteContents(), e.context), applyContextPrefix(e.content.toLiteContents(), e.context),
@@ -217,24 +244,13 @@ internal class CompactionCoordinator(
) )
else -> null else -> null
} }
msg
} }
val capped = if (pastTurns.size > MAX_SEEDED_MESSAGES) pastTurns.takeLast(MAX_SEEDED_MESSAGES) else pastTurns
val config = LiteConversationConfig(
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
initialMessages = capped,
tools = state.tools.map { it.tool },
)
return liteLlm.createConversation(config).also { state.liteConvRef.set(it) }
} }
companion object { companion object {
private const val KEEP_RECENT_TURNS = 4 private const val KEEP_RECENT_TURNS = 4
private const val MAX_SEEDED_MESSAGES = 50 private const val MAX_SEEDED_MESSAGES = 50
/** Минимум удаляемых ходов чтобы BackgroundScheduler trigger'ил skill mining. */ /** Минимум удаляемых ходов чтобы SkillMiningComponent trigger'ил skill mining. */
private const val MIN_COMPACTION_FOR_MINING = 10 private const val MIN_COMPACTION_FOR_MINING = 10
} }
} }
@@ -12,9 +12,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import mu.KotlinLogging import mu.KotlinLogging
@@ -27,7 +25,6 @@ import pw.binom.agentik.outbox.Event as ProtoEvent
import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Message as ProtoMessage import pw.binom.agentik.proto.Message as ProtoMessage
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.journal.Content import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore import pw.binom.agentik.journal.MutableConversationStore
@@ -47,9 +44,7 @@ import pw.binom.litert.LiteToolCall
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.llm.tools.LlmReflector import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.llm.tools.ContextCompactor import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.toolsets.NamedTool
class ConversationLoop( class ConversationLoop(
record: ConversationRecord, record: ConversationRecord,
@@ -59,8 +54,30 @@ class ConversationLoop(
private val reflectionStore: ReflectionStore?, private val reflectionStore: ReflectionStore?,
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore, private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
private val llm: LiteLlm, private val llm: LiteLlm,
/**
* Базовый системный промпт, который задаётся беседе при создании.
* В runtime может перетираться через [systemPromptResolver] перед каждым
* `sendStreamContents` (LiteRT v12 mutable setters сами делают no-op,
* если значение эквивалентно).
*/
private val systemPrompt: String, private val systemPrompt: String,
private val tools: List<NamedTool> = emptyList(), /**
* Лямбда, которая возвращает актуальный системный промпт в момент
* каждого `sendStreamContents`. По дефолту — возвращает [systemPrompt]
* без изменений. Production-конфигурация (ChatAgent) подменяет её на
* `{ agent.buildRuntimeSystemPrompt() }`, что добавляет к базе динамические
* секции от установленных компонент (`systemProviders`).
*/
internal var systemPromptResolver: () -> String = { systemPrompt },
private val tools: List<LiteTool> = emptyList(),
/**
* Лямбда, которая возвращает актуальный список тулов в момент каждого
* `sendStreamContents`. По дефолту — фиксированный [tools] снимок.
* Production-конфигурация (ChatAgent) подменяет её на
* `{ collectTools() }` — добавляет/убирает тулы от `toolProviders`
* компонент на лету.
*/
internal var toolsResolver: () -> List<LiteTool> = { tools },
private val toolsetDispatch: ToolsetDispatchPolicy? = null, private val toolsetDispatch: ToolsetDispatchPolicy? = null,
private val memoryPrefetcher: MemoryPrefetcher? = null, private val memoryPrefetcher: MemoryPrefetcher? = null,
private val memoryReviewer: MemoryReviewer? = null, private val memoryReviewer: MemoryReviewer? = null,
@@ -69,8 +86,6 @@ class ConversationLoop(
private val compressionThreshold: Double = 0.8, private val compressionThreshold: Double = 0.8,
private val contextCompactor: ContextCompactor? = null, private val contextCompactor: ContextCompactor? = null,
private val reflector: LlmReflector? = null, private val reflector: LlmReflector? = null,
private val skillMiner: SkillMiner? = null,
private val skillMiningStore: SkillStore? = null,
) : ProtoConversation, AutoCloseable { ) : ProtoConversation, AutoCloseable {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -90,10 +105,7 @@ class ConversationLoop(
conversationId = state.id, conversationId = state.id,
) )
/** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */ private val toolsByName: MutableMap<String, LiteTool> = tools.associateBy { it.name }.toMutableMap()
private val backgroundEvents = BackgroundEventBus()
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher) private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher)
@@ -107,33 +119,29 @@ class ConversationLoop(
workingMemory = workingMemoryStore, workingMemory = workingMemoryStore,
liteLlm = llm, liteLlm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
backgroundEvents = backgroundEvents, events = events,
now = ::now,
) )
private val toolDispatcher = ToolDispatcher( private val toolDispatcher = ToolDispatcher(
state = state, state = state,
messageStore = messageStore, messageStore = messageStore,
events = events, events = events,
backgroundEvents = backgroundEvents,
toolsByName = toolsByName, toolsByName = toolsByName,
toolsetDispatch = toolsetDispatch, toolsetDispatch = toolsetDispatch,
newId = ::newId, newId = ::newId,
encodeArgsJson = ::encodeArgsJson,
now = ::now, now = ::now,
) )
private val backgroundScheduler = BackgroundScheduler( private val reflectionScheduler = ReflectionScheduler(
state = state, state = state,
workingMemory = workingMemoryStore, workingMemory = workingMemoryStore,
config = BackgroundConfig( config = ReflectionConfig(
memoryReviewer = memoryReviewer,
memoryStore = memoryStoreForReview,
reflectionStore = reflectionStore,
reflector = reflector, reflector = reflector,
skillMiner = skillMiner, reflectionStore = reflectionStore,
skillMiningStore = skillMiningStore,
), ),
backgroundEvents = backgroundEvents, eventStore = eventStore,
conversationIdProvider = { id },
).also { it.start(agentScope) } ).also { it.start(agentScope) }
override val id: String get() = state.id override val id: String get() = state.id
@@ -226,10 +234,12 @@ class ConversationLoop(
override fun close() { override fun close() {
if (state.isClosed) return if (state.isClosed) return
state.markClosed() state.markClosed()
// Emit Closing event BEFORE agentScope.cancel() — BackgroundScheduler's подписка // Emit Closing event BEFORE agentScope.cancel() — ReflectionScheduler's подписка
// ловит это и делает final reflection + skill mining (last chance вытащить insights). // ловит это и делает final reflection (last chance вытащить insights).
// После cancel() подписка умерла бы. // После cancel() подписка умерла бы.
backgroundEvents.tryEmit(ConversationLifecycleEvent.Closing(conversationId = id)) runCatching {
events.tryEmit(pw.binom.agentik.outbox.Event.ConversationClosing(date = now(), conversationId = id))
}
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } } state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
runCatching { runBlocking { activeTurn?.cancelAndJoin() } } runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
agentScope.cancel() agentScope.cancel()
@@ -237,10 +247,6 @@ class ConversationLoop(
suspend fun forceCompactNow(): Boolean = compactor.forceCompactNow() suspend fun forceCompactNow(): Boolean = compactor.forceCompactNow()
internal fun registerToolForTest(name: String, tool: LiteTool) {
toolDispatcher.registerToolForTest(name, tool)
}
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) { private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
val wasInterruptedAtEntry = interrupted.get() val wasInterruptedAtEntry = interrupted.get()
if (!state.isTemporal) { if (!state.isTemporal) {
@@ -275,7 +281,7 @@ class ConversationLoop(
val conv = try { val conv = try {
compactor.getOrCreateLiteConversation( compactor.getOrCreateLiteConversation(
systemPrompt = systemPrompt, systemPrompt = systemPromptResolver(),
excludeUserSourceId = if (state.isTemporal) null else userRecord.id, excludeUserSourceId = if (state.isTemporal) null else userRecord.id,
) )
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -307,6 +313,10 @@ class ConversationLoop(
val collectedCalls = mutableListOf<LiteToolCall>() val collectedCalls = mutableListOf<LiteToolCall>()
if (pendingParts != null) { if (pendingParts != null) {
val lc = state.liteConvRef.get() ?: return val lc = state.liteConvRef.get() ?: return
// Hot-swap перед каждым sendStreamContents — v12 setter no-op
// если значение эквивалентно текущему (см. LiteConversation.systemInstruction).
lc.systemInstruction = systemPromptResolver()
lc.tools = toolsResolver()
try { try {
lc.sendStreamContents(pendingParts).collect { delta -> lc.sendStreamContents(pendingParts).collect { delta ->
if (delta.text.isNotEmpty()) { if (delta.text.isNotEmpty()) {
@@ -359,6 +369,10 @@ class ConversationLoop(
try { try {
val collectedPostTool = mutableListOf<LiteToolCall>() val collectedPostTool = mutableListOf<LiteToolCall>()
val lc = state.liteConvRef.get() ?: return val lc = state.liteConvRef.get() ?: return
// Hot-swap перед follow-up — на случай если компонент
// изменил toolset/soul/skills между turn'ами.
lc.systemInstruction = systemPromptResolver()
lc.tools = toolsResolver()
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp -> lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
if (followUp.text.isNotEmpty()) { if (followUp.text.isNotEmpty()) {
reply.append(followUp.text) reply.append(followUp.text)
@@ -452,9 +466,9 @@ class ConversationLoop(
) )
} }
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus // ReflectionScheduler is event-driven — подписан на outbox
// (compaction/lifecycle/tool-failure events). Никаких interval-based // (Event.ConversationClosing / Event.ToolFailed). Никаких
// вызовов сюда больше не идёт. См. BackgroundScheduler.kt. // interval-based вызовов сюда больше не идёт. См. ReflectionScheduler.kt.
} }
} }
@@ -492,28 +506,6 @@ class ConversationLoop(
private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix) private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix)
private fun encodeArgsJson(arguments: Map<String, Any?>): String {
val el = JsonElement.serializer()
val obj = buildJsonObject {
arguments.forEach { (k, v) -> put(k, v.toJsonElement()) }
}
return Json.encodeToString(el, obj)
}
private fun Any?.toJsonElement(): JsonElement = when (this) {
null -> JsonNull
is Boolean -> JsonPrimitive(this)
is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
is Map<*, *> -> buildJsonObject {
this@toJsonElement.forEach { (k, v) ->
put(k.toString(), v.toJsonElement())
}
}
is List<*> -> JsonArray(this.map { it.toJsonElement() })
else -> JsonPrimitive(toString())
}
companion object { companion object {
private const val MAX_TOOL_LOOPS = 16 private const val MAX_TOOL_LOOPS = 16
} }
@@ -3,12 +3,12 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteTool
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import pw.binom.agentik.toolsets.NamedTool
internal class ConversationState( internal class ConversationState(
initialRecord: ConversationRecord, initialRecord: ConversationRecord,
val tools: List<NamedTool>, val tools: List<LiteTool>,
val agentScope: CoroutineScope, val agentScope: CoroutineScope,
) { ) {
@Volatile @Volatile
@@ -2,57 +2,57 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mu.KotlinLogging import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.journal.Content import pw.binom.agentik.journal.Content
import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.ContextStore import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.llm.tools.LlmReflector import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OutboxStore
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
/** /**
* Background work подписчик на [BackgroundEventBus]. Заменяет старую interval-based * Event-driven reflection trigger. Подписан на [OutboxStore] и пишет
* логику (`maybeScheduleReview/Reflection/SkillMining` с `userTurnCount % N == 0`). * в [ReflectionStore] когда:
* *
* Подписки: * - `Event.ConversationClosing` для нашего `conversationId` — финальная
* - [ConversationLifecycleEvent.Closing] → финальный reflection + skill mining * рефлексия перед закрытием. Skill mining на Closing уже делает
* перед закрытием conversation (last chance вытащить insights). * [pw.binom.agentik.skill.mining.SkillMiningComponent].
* - [CompactionEvent.Triggered] → skill mining если `turnsToDelete > MIN_COMPACTION_FOR_MINING`. * - `Event.ToolFailed` для нашего `conversationId` — накопительно:
* Review уже сделан внутри CompactionCoordinator (`reviewPreCompaction`) — не дублируем. * 2+ фейлов за 60 сек запускают рефлексию (что-то идёт не так —
* - [ToolCallEvent.Failed] (накопительно) → reflection если 2+ фейлов в окне 60 сек * самоанализ полезен).
* (что-то пошло не так — самоанализ полезен).
* *
* НЕ текстовая инспекция, НЕ regex, НЕ interval-polling. Только структурные * НЕ текстовая инспекция, НЕ regex, НЕ interval-polling. Только структурные
* события с явным семантическим смыслом. * события с явным семантическим смыслом.
*
* Подписка идёт через outbox (а не через per-conversation BackgroundEventBus
* который был раньше): outbox — единый канал для всех событий (как клиентских,
* так и внутренних), persistent tail с TTL работает из коробки, а клиенты
* по тому же потоку могут самостоятельно видеть/логировать [Event.ToolFailed]
* без скрытой телеметрии.
*/ */
internal data class BackgroundConfig( internal data class ReflectionConfig(
val memoryReviewer: MemoryReviewer?,
val memoryStore: MemoryStore?,
val reflectionStore: ReflectionStore?,
val reflector: LlmReflector?, val reflector: LlmReflector?,
val skillMiner: SkillMiner?, val reflectionStore: ReflectionStore?,
val skillMiningStore: SkillStore?,
) )
internal class BackgroundScheduler( internal class ReflectionScheduler(
private val state: ConversationState, private val state: ConversationState,
private val workingMemory: ContextStore, private val workingMemory: ContextStore,
private val config: BackgroundConfig, private val config: ReflectionConfig,
private val backgroundEvents: BackgroundEventBus, private val eventStore: OutboxStore,
private val conversationIdProvider: () -> String,
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
private val lastSkillMiningAt = AtomicLong(0)
private val lastReflectionAt = AtomicLong(0) private val lastReflectionAt = AtomicLong(0)
/** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */ /** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */
@@ -65,17 +65,14 @@ internal class BackgroundScheduler(
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
if (subscriptionJob?.isActive == true) return if (subscriptionJob?.isActive == true) return
subscriptionJob = scope.launch { subscriptionJob = scope.launch {
// Merge all three event flows into one subscription scope. Each onEach
// returns Unit, so launchIn merges them as cold flows.
merge( merge(
backgroundEvents.lifecycleEvents eventStore.events(after = null)
.filterIsInstance<ConversationLifecycleEvent.Closing>() .filterIsInstance<CommonEvent.Conversation>()
.filter { it.event is Event.ConversationClosing && it.conversationId == conversationIdProvider() }
.onEach { onClosing() }, .onEach { onClosing() },
backgroundEvents.compactionEvents eventStore.events(after = null)
.filterIsInstance<CompactionEvent.Triggered>() .filterIsInstance<CommonEvent.Conversation>()
.onEach { onCompaction(it) }, .filter { it.event is Event.ToolFailed && it.conversationId == conversationIdProvider() }
backgroundEvents.toolCallEvents
.filterIsInstance<ToolCallEvent.Failed>()
.onEach { onToolFailure() }, .onEach { onToolFailure() },
).collect {} ).collect {}
} }
@@ -83,9 +80,7 @@ internal class BackgroundScheduler(
private suspend fun onClosing() { private suspend fun onClosing() {
if (state.isTemporal) return if (state.isTemporal) return
val convId = state.id runFinalReflection(state.id)
runFinalReflection(convId)
runFinalSkillMining(convId)
} }
private fun runFinalReflection(convId: String) { private fun runFinalReflection(convId: String) {
@@ -105,51 +100,6 @@ internal class BackgroundScheduler(
} }
} }
private fun runFinalSkillMining(convId: String) {
val miner = config.skillMiner ?: return
val store = config.skillMiningStore ?: return
state.agentScope.launch {
try {
val turns = recentTurns(miner.maxTurns)
if (turns.isEmpty()) return@launch
val mined = miner.mine(turns, store.catalog.skills)
for (s in mined) {
runCatching { store.upsert(s) }
.onFailure { log.warn(it) { "final skill mining upsert '${s.name}' failed: ${it.message}" } }
}
log.info { "final skill mining on close: conv=$convId turns=${turns.size} existing=${store.catalog.skills.size} mined=${mined.size}" }
} catch (e: Throwable) {
log.warn(e) { "final skill mining failed for $convId: ${e.message}" }
}
}
}
private fun onCompaction(event: CompactionEvent.Triggered) {
if (state.isTemporal) return
if (event.turnsToDelete < MIN_COMPACTION_FOR_MINING) return
val miner = config.skillMiner ?: return
val store = config.skillMiningStore ?: return
val now = System.currentTimeMillis()
// Debounce: не чаще раза в минуту
if (now - lastSkillMiningAt.get() < MINING_DEBOUNCE_MS) return
lastSkillMiningAt.set(now)
val convId = event.conversationId
state.agentScope.launch {
try {
val turns = recentTurns(miner.maxTurns)
if (turns.isEmpty()) return@launch
val mined = miner.mine(turns, store.catalog.skills)
for (s in mined) {
runCatching { store.upsert(s) }
.onFailure { log.warn(it) { "compaction skill mining upsert '${s.name}' failed: ${it.message}" } }
}
log.info { "compaction skill mining: conv=$convId turnsToDelete=${event.turnsToDelete} mined=${mined.size}" }
} catch (e: Throwable) {
log.warn(e) { "compaction skill mining failed for $convId: ${e.message}" }
}
}
}
private fun onToolFailure() { private fun onToolFailure() {
val reflector = config.reflector ?: return val reflector = config.reflector ?: return
if (state.isTemporal) return if (state.isTemporal) return
@@ -158,21 +108,18 @@ internal class BackgroundScheduler(
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val shouldReflect = synchronized(toolFailuresLock) { val shouldReflect = synchronized(toolFailuresLock) {
toolFailures.add(now) toolFailures.add(now)
// Trim old failures outside the window
val cutoff = now - FAILURE_WINDOW_MS val cutoff = now - FAILURE_WINDOW_MS
toolFailures.removeAll { it < cutoff } toolFailures.removeAll { it < cutoff }
toolFailures.size >= FAILURE_THRESHOLD toolFailures.size >= FAILURE_THRESHOLD
} }
if (!shouldReflect) return if (!shouldReflect) return
// Debounce reflection globally (не чаще раза в 5 мин)
if (now - lastReflectionAt.get() < REFLECTION_DEBOUNCE_MS) { if (now - lastReflectionAt.get() < REFLECTION_DEBOUNCE_MS) {
log.debug { "reflection debounced: ${toolFailures.size} failures accumulated but reflection fired recently" } log.debug { "reflection debounced: ${toolFailures.size} failures accumulated but reflection fired recently" }
return return
} }
lastReflectionAt.set(now) lastReflectionAt.set(now)
// Clear failure window — fresh accounting period
synchronized(toolFailuresLock) { toolFailures.clear() } synchronized(toolFailuresLock) { toolFailures.clear() }
val convId = state.id val convId = state.id
@@ -212,10 +159,6 @@ internal class BackgroundScheduler(
filterIsInstance<Content.Text>().joinToString("\n") { it.body } filterIsInstance<Content.Text>().joinToString("\n") { it.body }
companion object { companion object {
/** Минимум ходов, удаляемых compaction'ом, чтобы trigger'ить skill mining. */
private const val MIN_COMPACTION_FOR_MINING = 10
/** Дебаунс skill mining между запусками. */
private const val MINING_DEBOUNCE_MS = 60_000L
/** Debounce reflection между запусками (накопительный, не per-failure). */ /** Debounce reflection между запусками (накопительный, не per-failure). */
private const val REFLECTION_DEBOUNCE_MS = 300_000L private const val REFLECTION_DEBOUNCE_MS = 300_000L
/** Сколько tool-failures в окне должно накопиться чтобы trigger reflection. */ /** Сколько tool-failures в окне должно накопиться чтобы trigger reflection. */
@@ -1,22 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.toolsets.NamedTool
/**
* Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style).
*
* Возвращает два NamedTool'а:
* - `skill_save(name, description, body)` — создать или обновить скил;
* - `skill_delete(name)` — архивировать скил.
*
* Оба оборачивают [SkillStore]; агент может дёргать их в любой момент,
* не только во время review-loop.
*/
object SkillToolsFactory {
fun create(store: SkillStore): List<NamedTool> = listOf(
NamedTool("skill_save", SkillSaveTool(store)),
NamedTool("skill_delete", SkillDeleteTool(store)),
)
}
@@ -12,17 +12,14 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.litert.LiteToolCall import pw.binom.litert.LiteToolCall
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
internal class ToolDispatcher( internal class ToolDispatcher(
private val state: ConversationState, private val state: ConversationState,
private val messageStore: MutableJournalStore, private val messageStore: MutableJournalStore,
private val events: ConversationEvents, private val events: ConversationEvents,
private val backgroundEvents: BackgroundEventBus, private val toolsByName: MutableMap<String, LiteTool>,
private val toolsByName: MutableMap<String, NamedTool>,
private val toolsetDispatch: ToolsetDispatchPolicy?, private val toolsetDispatch: ToolsetDispatchPolicy?,
private val newId: (String) -> String, private val newId: (String) -> String,
private val encodeArgsJson: (Map<String, Any?>) -> String,
private val now: () -> Instant, private val now: () -> Instant,
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -32,14 +29,11 @@ internal class ToolDispatcher(
val currentToolJob: Job? get() = _currentToolJob val currentToolJob: Job? get() = _currentToolJob
internal fun registerToolForTest(name: String, tool: LiteTool) {
toolsByName[name] = NamedTool(name = name, tool = tool)
}
suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange { suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange {
val callId = newId("tc") val callId = newId("tc")
val resultId = newId("tr") val resultId = newId("tr")
val argsJson = encodeArgsJson(call.arguments) // v10: LiteToolCall.arguments — уже raw JSON-строка (раньше была Map<String, Any?>).
val argsJson = call.arguments
val nowTs = now() val nowTs = now()
val startMs = System.currentTimeMillis() val startMs = System.currentTimeMillis()
@@ -65,7 +59,7 @@ internal class ToolDispatcher(
log.warn { "tool '${call.name}' requested but not registered" } log.warn { "tool '${call.name}' requested but not registered" }
"[tool not found: ${call.name}]" "[tool not found: ${call.name}]"
} else { } else {
t.tool.invoke(argsJson) t.invoke(argsJson)
} }
} else { } else {
val d = toolsetDispatch val d = toolsetDispatch
@@ -95,13 +89,20 @@ internal class ToolDispatcher(
val resultAt = now() val resultAt = now()
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText)) events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
// Эмитим background event — другие компоненты (BackgroundScheduler) // Только реальные падения тула попадают в outbox как background-event
// решают, делать ли что-то. Cancellation = not a failure (не эмитим Failed). // (reflection-подобные потребители). Cancellation — not a failure,
// успех — тоже без фонового события (ToolResult уже всё сказал).
val durationMs = System.currentTimeMillis() - startMs val durationMs = System.currentTimeMillis() - startMs
if (failureError != null) { if (failureError != null) {
backgroundEvents.tryEmit(ToolCallEvent.Failed(toolName = call.name, error = failureError)) events.tryEmit(
} else if (resultText != "[cancelled by user]") { ProtoEvent.ToolFailed(
backgroundEvents.tryEmit(ToolCallEvent.Succeeded(toolName = call.name, durationMs = durationMs)) date = resultAt,
toolCallId = callId,
toolName = call.name,
message = failureError,
durationMs = durationMs,
),
)
} }
if (!state.isTemporal) { if (!state.isTemporal) {
@@ -0,0 +1,52 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.litert.LiteTool
/**
* Подключает память агенту:
* - Тулы memory_save / memory_read / memory_list / memory_delete —
* через [MemoryToolProvider].
* - Секция системного промпта с категориями и форматом записи —
* через [MemorySystemProvider].
*
* Память опциональна — если [MemoryStore] == null, компонент не регистрирует
* ни провайдеры, ни system-секцию. Поведение идентично прошлому (когда
* `memoryStore=null` ничего не добавлялось).
*/
class MemoryComponent(
private val store: MemoryStore?,
) : Component {
private var toolProvider: MemoryToolProvider? = null
override fun install(agent: MutableAgent) {
if (store == null) return
val tp = MemoryToolProvider(store)
agent.toolProviders.add(tp)
toolProvider = tp
agent.systemProviders.add(MemorySystemProvider())
}
override fun uninstall(agent: MutableAgent) {
toolProvider?.let { agent.toolProviders.remove(it) }
agent.systemProviders.removeAll { it is MemorySystemProvider }
}
}
private class MemoryToolProvider(
private val store: MemoryStore,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> =
MemoryToolsFactory.create(store)
}
private class MemorySystemProvider : SystemPromptProvider {
override fun getSection(conversationId: String): String =
MemorySystemGuidance.MEMORY_GUIDANCE
}
@@ -1,16 +1,6 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.standalone.agent.memory
import kotlinx.serialization.json.Json import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import pw.binom.agentik.memory.DefaultMemoryTools import pw.binom.agentik.memory.DefaultMemoryTools
import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote import pw.binom.agentik.memory.MemoryNote
@@ -19,13 +9,19 @@ import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemorySource import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.NewMemoryNote import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.toolsets.liteToolSuspend
import pw.binom.litert.LiteTool
import java.util.UUID import java.util.UUID
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.serialization.json.jsonObject
import pw.binom.agentik.toolsets.NamedTool
private val MemoryToolsJson = Json { ignoreUnknownKeys = true; isLenient = true } private val MemoryResponseJson = kotlinx.serialization.json.Json { encodeDefaults = true }
private val MemoryResponseJson = Json { encodeDefaults = true }
private val ToolInvocationJson = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
isLenient = false
coerceInputValues = true
explicitNulls = false
}
/** /**
* Превращает [NewMemoryNote] (из reviewer'а) в полноценную [MemoryNote], * Превращает [NewMemoryNote] (из reviewer'а) в полноценную [MemoryNote],
@@ -48,169 +44,89 @@ fun materializeReviewNote(n: NewMemoryNote, conversationId: String? = null): Mem
/** Сериализация результатов поиска для LLM — компактный JSON. */ /** Сериализация результатов поиска для LLM — компактный JSON. */
fun serializeSearchResults(results: List<MemorySearchResult>): String { fun serializeSearchResults(results: List<MemorySearchResult>): String {
val items = results.map { r -> val items = results.map { r ->
buildJsonObject { kotlinx.serialization.json.buildJsonObject {
put("id", JsonPrimitive(r.note.id)) put("id", kotlinx.serialization.json.JsonPrimitive(r.note.id))
put("category", JsonPrimitive(r.note.category.id)) put("category", kotlinx.serialization.json.JsonPrimitive(r.note.category.id))
put("score", JsonPrimitive(r.score)) put("score", kotlinx.serialization.json.JsonPrimitive(r.score))
put("content", JsonPrimitive(truncate(r.note.content, 300))) put("content", kotlinx.serialization.json.JsonPrimitive(truncate(r.note.content, 300)))
} }
} }
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } }) return MemoryResponseJson.encodeToString(
kotlinx.serialization.json.JsonElement.serializer(),
kotlinx.serialization.json.buildJsonArray { items.forEach { add(it) } },
)
} }
/** Сериализация списка заметок для LLM. */ /** Сериализация списка заметок для LLM. */
fun serializeNotes(notes: List<MemoryNote>): String { fun serializeNotes(notes: List<MemoryNote>): String {
val items = notes.map { n -> val items = notes.map { n ->
buildJsonObject { kotlinx.serialization.json.buildJsonObject {
put("id", JsonPrimitive(n.id)) put("id", kotlinx.serialization.json.JsonPrimitive(n.id))
put("category", JsonPrimitive(n.category.id)) put("category", kotlinx.serialization.json.JsonPrimitive(n.category.id))
put("content", JsonPrimitive(truncate(n.content, 300))) put("content", kotlinx.serialization.json.JsonPrimitive(truncate(n.content, 300)))
put("created_at", JsonPrimitive(n.createdAt.toString())) put("created_at", kotlinx.serialization.json.JsonPrimitive(n.createdAt.toString()))
} }
} }
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } }) return MemoryResponseJson.encodeToString(
kotlinx.serialization.json.JsonElement.serializer(),
kotlinx.serialization.json.buildJsonArray { items.forEach { add(it) } },
)
} }
private fun truncate(s: String, max: Int): String = private fun truncate(s: String, max: Int): String =
if (s.length <= max) s else s.substring(0, max) + "…" if (s.length <= max) s else s.substring(0, max) + "…"
private fun parseArgs(s: String): JsonObject = @Serializable
runCatching { MemoryToolsJson.parseToJsonElement(s).jsonObject }.getOrElse { JsonObject(emptyMap()) } internal data class SaveArgs(val category: String, val content: String)
private fun objString(s: String, key: String): String? { @Serializable
val v = parseArgs(s)[key] ?: return null internal data class ReadArgs(val query: String, val top_k: Int = 5, val category: String? = null)
return if (v is JsonPrimitive && v.isString) v.content else v.toString().trim('"')
}
private fun objInt(s: String, key: String): Int? = objString(s, key)?.toIntOrNull() @Serializable
internal data class ListArgs(val category: String? = null, val limit: Int = 20)
/** @Serializable
* Flat OpenAPI-спецификация тула (name/description/parameters) — формат, internal data class DeleteArgs(val id: String)
* который напрямую принимает LiteRT-LM (litert-google). litert-openai
* оборачивает её в OpenAI-формат сам. [name] совпадает с именем NamedTool.
*/
private fun toolDescribe(name: String, description: String, parameters: kotlinx.serialization.json.JsonObject): String =
buildJsonObject {
put("name", name)
put("description", description)
put("parameters", parameters)
}.toString()
/** parameters-блок JSON-Schema: {"type":"object","properties":{...},"required":[...]}. */
private fun objectSchema(
properties: kotlinx.serialization.json.JsonObject,
required: List<String> = emptyList(),
): kotlinx.serialization.json.JsonObject = buildJsonObject {
put("type", "object")
put("properties", properties)
if (required.isNotEmpty()) put("required", buildJsonArray { required.forEach { add(it) } })
}
/** memory_save(category, content) → upsert. */ /** memory_save(category, content) → upsert. */
internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun saveTool(store: MemoryStore): LiteTool = liteToolSuspend<SaveArgs>(
describeJson = toolDescribe( name = "memory_save",
"memory_save", description = DefaultMemoryTools.save.description,
DefaultMemoryTools.save.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
put("description", "user | world | preference")
}
putJsonObject("content") {
put("type", "string")
put("description", "the fact to remember")
}
},
required = listOf("category", "content"),
),
),
) { args -> ) { args ->
val category = objString(args, "category")?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() } val category = runCatching { MemoryCategory.fromId(args.category) }.getOrNull()
?: return@SyncLiteTool """{"error":"category required"}""" ?: return@liteToolSuspend """{"error":"category required"}"""
val content = objString(args, "content") if (args.content.isBlank()) return@liteToolSuspend """{"error":"content is blank"}"""
?: return@SyncLiteTool """{"error":"content required"}""" val note = materializeReviewNote(NewMemoryNote(category, args.content))
if (content.isBlank()) return@SyncLiteTool """{"error":"content is blank"}"""
val note = materializeReviewNote(NewMemoryNote(category, content))
store.upsert(note) store.upsert(note)
"""{"ok":true,"id":"${note.id}"}""" """{"ok":true,"id":"${note.id}"}"""
} }
/** memory_read(query, top_k?, category?) → search. */ /** memory_read(query, top_k?, category?) → search. */
internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun readTool(store: MemoryStore): LiteTool = liteToolSuspend<ReadArgs>(
describeJson = toolDescribe( name = "memory_read",
"memory_read", description = DefaultMemoryTools.read.description,
DefaultMemoryTools.read.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("query") {
put("type", "string")
put("description", "free-text query")
}
putJsonObject("top_k") {
put("type", "integer")
put("description", "максимум результатов (default 5)")
}
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
}
},
required = listOf("query"),
),
),
) { args -> ) { args ->
val query = objString(args, "query") ?: return@SyncLiteTool """{"error":"query required"}""" val category = args.category?.takeIf { it.isNotBlank() }
val topK = objInt(args, "top_k") ?: 5
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() } ?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
val results = store.search(MemorySearchQuery(query = query, topK = topK, category = category)) val results = store.search(MemorySearchQuery(query = args.query, topK = args.top_k, category = category))
serializeSearchResults(results) serializeSearchResults(results)
} }
/** memory_list(category?, limit?) → list. */ /** memory_list(category?, limit?) → list. */
internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun listTool(store: MemoryStore): LiteTool = liteToolSuspend<ListArgs>(
describeJson = toolDescribe( name = "memory_list",
"memory_list", description = DefaultMemoryTools.list.description,
DefaultMemoryTools.list.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
}
putJsonObject("limit") {
put("type", "integer")
put("description", "default 20")
}
},
),
),
) { args -> ) { args ->
val category = objString(args, "category")?.takeIf { it.isNotBlank() } val category = args.category?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() } ?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
val limit = objInt(args, "limit") ?: 20 val notes = store.list(category = category, limit = args.limit)
val notes = store.list(category = category, limit = limit)
serializeNotes(notes) serializeNotes(notes)
} }
/** memory_delete(id) → delete. */ /** memory_delete(id) → delete. */
internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool( internal fun deleteTool(store: MemoryStore): LiteTool = liteToolSuspend<DeleteArgs>(
describeJson = toolDescribe( name = "memory_delete",
"memory_delete", description = DefaultMemoryTools.delete.description,
DefaultMemoryTools.delete.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("id") {
put("type", "string")
put("description", "memory note id (mem-...)")
}
},
required = listOf("id"),
),
),
) { args -> ) { args ->
val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}""" if (store.delete(args.id)) """{"ok":true,"deleted":"${args.id}"}""" else """{"ok":false,"missing":"${args.id}"}"""
if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}"""
} }
@@ -1,34 +1,23 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete) * Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete)
* поверх конкретного [MemoryStore]. Каждый инструмент возвращает JSON-строку, * поверх конкретного [MemoryStore]. Каждый инструмент возвращает JSON-строку,
* совместимую с тем, что отдают остальные NamedTool'ы в проекте. * совместимую с тем, что отдают остальные LiteTool'ы в проекте.
*
* Реализация каждого тула — типизированный `@Serializable Args` + suspend handler —
* через `pw.binom.agentik.toolsets.liteToolSuspend` (сам построен на
* `pw.binom.litert.tools.kotlinx.serialization.liteTool`).
*/ */
object MemoryToolsFactory { object MemoryToolsFactory {
fun create(store: MemoryStore): List<NamedTool> = listOf( fun create(store: MemoryStore): List<LiteTool> = listOf(
NamedTool("memory_save", saveTool(store)), saveTool(store),
NamedTool("memory_read", readTool(store)), readTool(store),
NamedTool("memory_list", listTool(store)), listTool(store),
NamedTool("memory_delete", deleteTool(store)), deleteTool(store),
) )
} }
/**
* Адаптер из suspend-tool в синхронный [LiteTool]. LiteTool-контракт на
* JVM-движке — синхронный; [runBlocking] выполняет suspend-лямбду в том же
* потоке, что и сам LiteLlm-вызов (LiteTool.invoke синхронен).
*/
internal class SyncLiteTool(
private val describeJson: String,
private val handler: suspend (String) -> String,
) : LiteTool {
override fun describe(): String = describeJson
override fun invoke(arguments: String): String = runBlocking { handler(arguments) }
}
@@ -0,0 +1,36 @@
package pw.binom.agentik.standalone.agent.soul
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
/**
* Подключает "душу" — фиксированную секцию системного промпта,
* идущую первой (до базы, навыков, памяти).
*
* Используется для статичной персональной информации (persona, поведение),
* которую LLM должна видеть в каждом turn'е.
*/
class SoulComponent(
private val body: String?,
) : Component {
private var provider: SoulSystemProvider? = null
override fun install(agent: MutableAgent) {
if (body.isNullOrBlank()) return
val sp = SoulSystemProvider(body)
agent.systemProviders.add(sp)
provider = sp
}
override fun uninstall(agent: MutableAgent) {
provider?.let { agent.systemProviders.remove(it) }
}
}
private class SoulSystemProvider(
private val body: String,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String = body.trim()
}
@@ -1,46 +0,0 @@
package pw.binom.agentik.standalone.llm
import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteLlm
/**
* Factory для движка Google LiteRT-LM на JVM.
*
* `litert-google` опубликован только как Android AAR (с .so внутри), поэтому на JVM
* приходится вручную подгружать JNI-библиотеки LiteRT-LM, прежде чем инстанциировать
* движок. Эта функция:
*
* 1. Резолвит `pw.binom.litert.google.GoogleLiteLlm` через reflection.
* 2. Если процесс уже загрузил нативные библиотеки (`-Djava.library.path`) — успешно
* создаёт движок.
* 3. Если нет — кидает `IllegalStateException` с инструкцией по настройке.
*/
fun googleLiteLlmJvm(config: LiteConfig): LiteLlm {
val cls = try {
Class.forName("pw.binom.litert.google.GoogleLiteLlm")
} catch (e: ClassNotFoundException) {
error(
"litert-google classes are not on the classpath. " +
"Add 'pw.binom.litert:litert-google-android:6' as a runtime dependency " +
"to use the GOOGLE backend on JVM."
)
}
val ctor = cls.constructors.firstOrNull { it.parameterCount == 1 }
?: error("pw.binom.litert.google.GoogleLiteLlm constructor not found")
val engine = try {
ctor.newInstance(config)
} catch (e: UnsatisfiedLinkError) {
throw IllegalStateException(
"LiteRT-LM native libraries are not loaded. " +
"Extract .so/.dylib/.dll from litertlm-android-0.16.1.aar and pass " +
"-Djava.library.path=<dir>, or build :standalone for the androidJvm target.",
e,
)
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.targetException ?: e
}
@Suppress("UNCHECKED_CAST")
return engine as LiteLlm
}
private fun error(message: String): Nothing = throw IllegalStateException(message)
@@ -5,6 +5,7 @@ import pw.binom.litert.LiteBackend
import pw.binom.litert.LiteConfig import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteExperimental import pw.binom.litert.LiteExperimental
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import pw.binom.litert.google.googleLiteLlm
import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig
import pw.binom.litert.openai.openAiLiteLlm import pw.binom.litert.openai.openAiLiteLlm
@@ -39,7 +40,7 @@ data class LlmConfig(
fun createLlm(): LiteLlm = when (backend) { fun createLlm(): LiteLlm = when (backend) {
LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai).toLitertConfig()) LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai).toLitertConfig())
LlmBackend.GOOGLE -> googleLiteLlmJvm(google!!.toLiteConfig()) LlmBackend.GOOGLE -> googleLiteLlm(google!!.toLiteConfig())
} }
fun modelInfo(): String = when (backend) { fun modelInfo(): String = when (backend) {
@@ -0,0 +1,59 @@
package pw.binom.agentik.standalone.persistence
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.context.ksqlite.KsqliteContextStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Bundle из 4 ksqlite-сторов для standalone-агента.
*
* Internal helper `:standalone` — bundle нужен только агенту, поэтому не
* торчит наружу через публичный API модуля. Каждый store (conversation,
* message, working_memory, reflection) живёт в своём ksqlite-модуле;
* этот класс собирает их вокруг одной shared-connection и закрывает их
* в правильном порядке в [close].
*
* Lifecycle: открывает [SQLiteConnection] и возвращает 4 store'а. Каждый
* store сам прогоняет свою схему в конструкторе (`Schema.migrate(connection)`
* — idempotent `CREATE TABLE IF NOT EXISTS`), явных вызовов миграции в bundle
* нет. Caller ДОЛЖЕН вызвать [close] при завершении.
*
* Закрытие в правильном порядке: зависимые сторы → владелец connection.
*
* @param path путь к .db файлу, либо URI для in-memory/shared-cache.
*/
internal class SqliteStores internal constructor(
val connection: SQLiteConnection,
val conversations: MutableConversationStore,
val messages: MutableJournalStore,
val workingMemory: ContextStore,
val reflections: ReflectionStore,
) : AutoCloseable {
override fun close() {
conversations.close()
messages.close()
workingMemory.close()
reflections.close()
connection.close()
}
companion object {
fun open(path: String): SqliteStores = assemble(SQLiteConnection.open(path))
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
private fun assemble(conn: SQLiteConnection): SqliteStores = SqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteJournalStore(conn),
workingMemory = KsqliteContextStore(conn),
reflections = KsqliteReflectionStore(conn),
)
}
}
@@ -4,8 +4,8 @@ import kotlin.test.Test
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.reflection.Reflection import pw.binom.agentik.reflection.Reflection
import pw.binom.agentik.memory.MemorySystemGuidance
class ChatAgentReflectionTest { class ChatAgentReflectionTest {
@@ -13,9 +13,7 @@ class ChatAgentReflectionTest {
fun `buildSystemPrompt omits section when reflections empty`() { fun `buildSystemPrompt omits section when reflections empty`() {
val prompt = buildSystemPrompt( val prompt = buildSystemPrompt(
base = "base", base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = false, memoryEnabled = false,
soulBody = null,
reflections = emptyList(), reflections = emptyList(),
) )
assertFalse(prompt.contains("Self-reflection")) assertFalse(prompt.contains("Self-reflection"))
@@ -35,9 +33,7 @@ class ChatAgentReflectionTest {
) )
val prompt = buildSystemPrompt( val prompt = buildSystemPrompt(
base = "base", base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = false, memoryEnabled = false,
soulBody = null,
reflections = listOf(r), reflections = listOf(r),
) )
assertTrue(prompt.contains("Self-reflection")) assertTrue(prompt.contains("Self-reflection"))
@@ -48,7 +44,10 @@ class ChatAgentReflectionTest {
} }
@Test @Test
fun `buildSystemPrompt places reflection section after memory and before soul prepend`() { fun `buildSystemPrompt places reflection section after memory section`() {
// Memory теперь приходит от MemoryComponent → SystemPromptProvider; порядок
// провайдеров определяется порядком install(). Тест проверяет базовый
// порядок внутри buildSystemPrompt — reflection идёт после памяти.
val r = Reflection( val r = Reflection(
id = "r1", id = "r1",
conversationId = null, conversationId = null,
@@ -60,15 +59,15 @@ class ChatAgentReflectionTest {
) )
val prompt = buildSystemPrompt( val prompt = buildSystemPrompt(
base = "base", base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = true, memoryEnabled = true,
soulBody = "I am a soul",
reflections = listOf(r), reflections = listOf(r),
) )
// soul первый, reflection последняя assertTrue(prompt.contains("base"), "base missing: $prompt")
val soulIdx = prompt.indexOf("I am a soul") assertTrue(prompt.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(20)), "memory section missing: $prompt")
assertTrue(prompt.contains("Self-reflection"), "reflection section missing: $prompt")
val baseIdx = prompt.indexOf("base")
val memIdx = prompt.indexOf(MemorySystemGuidance.MEMORY_GUIDANCE.take(20))
val reflIdx = prompt.indexOf("Self-reflection") val reflIdx = prompt.indexOf("Self-reflection")
assertTrue(soulIdx >= 0 && reflIdx >= 0) assertTrue(baseIdx < memIdx && memIdx < reflIdx, "expected base < memory < reflection, got $baseIdx/$memIdx/$reflIdx")
assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection")
} }
} }
@@ -10,13 +10,14 @@ import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.outbox.Event as ProtoEvent import pw.binom.agentik.outbox.Event as ProtoEvent
import pw.binom.agentik.skill.mining.SkillReadTool
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.storage.ksqlite.KsqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteConversationConfig
@@ -37,16 +38,15 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
class ChatAgentTest { class ChatAgentTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
private lateinit var fakeLlm: FakeLiteLlm private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest @BeforeTest
fun setup() { fun setup() {
sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}") sqliteStores = SqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm() fakeLlm = FakeLiteLlm()
} }
@@ -56,25 +56,28 @@ class ChatAgentTest {
} }
private fun newAgent( private fun newAgent(
sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores, sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores = this.sqliteStores,
llm: LiteLlm = this.fakeLlm, llm: LiteLlm = this.fakeLlm,
tools: List<NamedTool> = emptyList(), tools: List<LiteTool> = emptyList(),
skills: SkillCatalog = SkillCatalog.EMPTY, skillStore: pw.binom.agentik.skills.SkillStore? = null,
): ChatAgent = ChatAgent( ): ChatAgent {
val agent = ChatAgent(
id = "agentik", id = "agentik",
mutableConversationStore = sqliteStores.conversations, mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages, messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory, workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections, reflectionStore = sqliteStores.reflections,
skillStore = skillStore,
llm = llm, llm = llm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = LlmBackend.OPENAI, backend = LlmBackend.OPENAI,
systemPrompt = "be brief", systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
), ),
tools = tools,
skills = skills,
) )
tools.forEach { agent.addTestTool(it) }
return agent
}
@Test @Test
fun `createConversation does NOT seed system prompt into working memory`() = runTest { fun `createConversation does NOT seed system prompt into working memory`() = runTest {
@@ -93,14 +96,13 @@ class ChatAgentTest {
@Test @Test
fun `skills are appended to the system prompt passed to LLM`() = runTest { fun `skills are appended to the system prompt passed to LLM`() = runTest {
// Skills добавляются в system prompt на лету при сборке ChatAgent. // Скиллы теперь приходят через SkillComponent → SkillSystemProvider →
// Проверяем это через то, что увидит LLM — systemInstruction в // buildRuntimeSystemPrompt() при каждом turn'е. Проверяем через
// LiteConversationConfig (а не через working_memory, куда теперь // systemInstruction, который увидит LLM.
// ничего про system prompt не пишется). val skillStore = InMemorySkillStore(
val skills = SkillCatalog(
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
) )
val agent = newAgent(skills = skills) val agent = newAgent(skillStore = skillStore)
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
fakeLlm.reply = "ok" fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi"))) conv.send(listOf(Content.Text("hi")))
@@ -116,10 +118,10 @@ class ChatAgentTest {
@Test @Test
fun `read_skill tool is registered when skills present`() = runTest { fun `read_skill tool is registered when skills present`() = runTest {
val skills = SkillCatalog( val skillStore = InMemorySkillStore(
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
) )
val agent = newAgent(skills = skills) val agent = newAgent(skillStore = skillStore)
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
fakeLlm.reply = "ok" fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi"))) conv.send(listOf(Content.Text("hi")))
@@ -419,14 +421,15 @@ class ChatAgentTest {
// LLM скриптован: tool call. // LLM скриптован: tool call.
fakeLlm.scriptedReplies = mutableListOf( fakeLlm.scriptedReplies = mutableListOf(
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))), FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to """{"q":"hi"}""")),
) )
val echoTool = object : LiteTool { val echoTool = object : LiteTool {
override val name: String = "echo_tool"
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}""" override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
override fun invoke(arguments: String): String = """{"echo":$arguments}""" override fun invoke(arguments: String): String = """{"echo":$arguments}"""
} }
agent.registerToolForTest("echo_tool", echoTool) agent.addTestTool(echoTool)
// Подписываемся ДО send — SharedFlow без replay // Подписываемся ДО send — SharedFlow без replay
val events = mutableListOf<ProtoEvent>() val events = mutableListOf<ProtoEvent>()
@@ -472,7 +475,7 @@ class ChatAgentTest {
// Поднимаем file-backed БД, создаём temp-беседу // Поднимаем file-backed БД, создаём temp-беседу
sqliteStores.close() sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = KsqliteStores.open(dbPath) sqliteStores = SqliteStores.open(dbPath)
val agent1 = ChatAgent( val agent1 = ChatAgent(
id = "agentik", id = "agentik",
mutableConversationStore = sqliteStores.conversations, mutableConversationStore = sqliteStores.conversations,
@@ -492,7 +495,7 @@ class ChatAgentTest {
// Переоткрываем БД — temp-беседа не должна пережить рестарт // Переоткрываем БД — temp-беседа не должна пережить рестарт
sqliteStores.close() sqliteStores.close()
sqliteStores = KsqliteStores.open(dbPath) sqliteStores = SqliteStores.open(dbPath)
val agent2 = ChatAgent( val agent2 = ChatAgent(
id = "agentik", id = "agentik",
mutableConversationStore = sqliteStores.conversations, mutableConversationStore = sqliteStores.conversations,
@@ -514,7 +517,7 @@ class ChatAgentTest {
fun `non-temp conversation persists across agent instances`() = runTest { fun `non-temp conversation persists across agent instances`() = runTest {
sqliteStores.close() sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = KsqliteStores.open(dbPath) sqliteStores = SqliteStores.open(dbPath)
val agent1 = ChatAgent( val agent1 = ChatAgent(
id = "agentik", id = "agentik",
mutableConversationStore = sqliteStores.conversations, mutableConversationStore = sqliteStores.conversations,
@@ -532,7 +535,7 @@ class ChatAgentTest {
val id = conv.id val id = conv.id
sqliteStores.close() sqliteStores.close()
sqliteStores = KsqliteStores.open(dbPath) sqliteStores = SqliteStores.open(dbPath)
val agent2 = ChatAgent( val agent2 = ChatAgent(
id = "agentik", id = "agentik",
mutableConversationStore = sqliteStores.conversations, mutableConversationStore = sqliteStores.conversations,
@@ -564,11 +567,12 @@ class ChatAgentTest {
@Test @Test
fun `tool-call loop executes registered tool and feeds result back`() = runTest { fun `tool-call loop executes registered tool and feeds result back`() = runTest {
val echoTool = object : LiteTool { val echoTool = object : LiteTool {
override val name: String = "echo"
override fun describe(): String = """{"type":"function","function":{"name":"echo"}}""" override fun describe(): String = """{"type":"function","function":{"name":"echo"}}"""
override fun invoke(arguments: String): String = "echoed: $arguments" override fun invoke(arguments: String): String = "echoed: $arguments"
} }
val toolLlm = ToolLoopFakeLiteLlm() val toolLlm = ToolLoopFakeLiteLlm()
val agent = newAgent(llm = toolLlm, tools = listOf(NamedTool("echo", echoTool))) val agent = newAgent(llm = toolLlm, tools = listOf(echoTool))
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool"))) conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
@@ -624,6 +628,12 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
return object : LiteConversation { return object : LiteConversation {
private val hist = mutableListOf<LiteMessage>() private val hist = mutableListOf<LiteMessage>()
override val history: List<LiteMessage> get() = hist.toList() override val history: List<LiteMessage> get() = hist.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun replaceHistory(newHistory: List<LiteMessage>) {
hist.clear()
hist.addAll(newHistory)
}
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt))) override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> { override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
hist.add(LiteMessage(LiteRole.USER, contents)) hist.add(LiteMessage(LiteRole.USER, contents))
@@ -632,7 +642,7 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
LiteDelta( LiteDelta(
text = "", text = "",
isDone = true, isDone = true,
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))), toolCalls = listOf(LiteToolCall(name = "echo", arguments = """{"x":"hi"}""")),
), ),
) )
} }
@@ -2,7 +2,7 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.storage.ksqlite.KsqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.agentik.toolsets.ToolsetContribution import pw.binom.agentik.toolsets.ToolsetContribution
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
@@ -20,6 +20,7 @@ import kotlin.test.assertTrue
class ChatAgentToolsetsTest { class ChatAgentToolsetsTest {
private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool { private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response override fun invoke(arguments: String) = response
} }
@@ -39,8 +40,8 @@ class ChatAgentToolsetsTest {
private fun newAgent( private fun newAgent(
toolsets: List<ToolsetContribution> = emptyList(), toolsets: List<ToolsetContribution> = emptyList(),
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> { ): Pair<ChatAgent, pw.binom.agentik.standalone.persistence.SqliteStores> {
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}") val sqliteStores = SqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
val agent = ChatAgent( val agent = ChatAgent(
id = "test-agent", id = "test-agent",
mutableConversationStore = sqliteStores.conversations, mutableConversationStore = sqliteStores.conversations,
@@ -183,9 +184,18 @@ class ChatAgentToolsetsTest {
// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов. // Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов.
internal fun ChatConversation.systemPromptForTest(): String { internal fun ChatConversation.systemPromptForTest(): String {
// Через рефлексию достаём private val systemPrompt. // v12+: секция "## Toolsets" приходит от ToolsetSystemProvider'а в
val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true } // buildRuntimeSystemPrompt() (динамически). Получаем промт агента
return f.get(this) as String // через тот же путь, что и production turn'ы.
val agentField = ChatAgent::class.java.declaredFields.first { it.name == "components" }.apply { isAccessible = true }
val agent = agentField.let {
// Доступ через компоненты неудобен — лучше через саму agent-instance.
// ChatConversation хранит systemPromptResolver, который замыкается над ChatAgent.
val resolverField = this::class.java.declaredFields.first { it.name == "systemPromptResolver" }.apply { isAccessible = true }
@Suppress("UNCHECKED_CAST")
(resolverField.get(this) as () -> String).invoke()
}
return agent
} }
internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String { internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String {
@@ -11,7 +11,7 @@ import pw.binom.agentik.memory.md.KeywordMdReviewer
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.storage.ksqlite.KsqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.agentik.proto.Content as ProtoContent import pw.binom.agentik.proto.Content as ProtoContent
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteConversationConfig
@@ -40,12 +40,12 @@ import pw.binom.agentik.llm.tools.SummaryTurn
*/ */
class CompactionTest { class CompactionTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
private lateinit var fakeLlm: FakeLiteLlm private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest @BeforeTest
fun setup() { fun setup() {
sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}") sqliteStores = SqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm() fakeLlm = FakeLiteLlm()
} }
@@ -18,7 +18,7 @@ import pw.binom.litert.LiteToolCall
internal class FakeLiteLlm : LiteLlm { internal class FakeLiteLlm : LiteLlm {
sealed class Reply { sealed class Reply {
data class Text(val text: String) : Reply() data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, Map<String, Any?>>>) : Reply() data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
} }
override val backendName: String = "fake" override val backendName: String = "fake"
@@ -67,6 +67,8 @@ internal class FakeLiteConversation(
val initialMessages: List<LiteMessage> = config.initialMessages val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList() private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList() override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> = override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt))) sendStreamContents(listOf(LiteContentPart.Text(prompt)))
@@ -104,6 +106,11 @@ internal class FakeLiteConversation(
} }
} }
} }
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String { override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt)) parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply return parent.reply
@@ -0,0 +1,28 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/** Минимальный in-memory [SkillStore] для commonTest. */
class InMemorySkillStore(initial: List<SkillFile> = emptyList()) : SkillStore {
private val lock = ReentrantLock()
private val active = LinkedHashMap<String, SkillFile>().apply {
initial.forEach { put(it.name, it) }
}
override val catalog: SkillCatalog
get() = lock.withLock { SkillCatalog(active.values.toList()) }
override fun upsert(skill: SkillFile) = lock.withLock {
active[skill.name] = skill
}
override fun remove(name: String): Boolean = lock.withLock {
active.remove(name) != null
}
override fun close() = Unit
}
@@ -30,7 +30,7 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.storage.ksqlite.KsqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.agentik.llm.tools.ContextCompactor import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.SummaryTurn import pw.binom.agentik.llm.tools.SummaryTurn
import kotlin.test.AfterTest import kotlin.test.AfterTest
@@ -49,13 +49,13 @@ import kotlin.time.Instant
*/ */
class MemoryWiringTest { class MemoryWiringTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
private lateinit var fakeLlm: FakeLiteLlm private lateinit var fakeLlm: FakeLiteLlm
private lateinit var root: Path private lateinit var root: Path
@BeforeTest @BeforeTest
fun setup() { fun setup() {
sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}") sqliteStores = SqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm() fakeLlm = FakeLiteLlm()
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}") root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
SystemFileSystem.createDirectories(root, mustCreate = true) SystemFileSystem.createDirectories(root, mustCreate = true)
@@ -173,7 +173,7 @@ class MemoryWiringTest {
assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names) assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names)
// Каждый tool описывается валидной JSON-схемой: // Каждый tool описывается валидной JSON-схемой:
for (t in tools) { for (t in tools) {
assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}") assertTrue(t.describe().contains("\"description\""), "describe() for ${t.name}")
} }
system.close() system.close()
} }
@@ -182,23 +182,23 @@ class MemoryWiringTest {
fun `memory_save tool round-trips a note through the store`() = runBlocking { fun `memory_save tool round-trips a note through the store`() = runBlocking {
val system = openMdMemorySystem(root) val system = openMdMemorySystem(root)
val tools = MemoryToolsFactory.create(system.store).associateBy { it.name } val tools = MemoryToolsFactory.create(system.store).associateBy { it.name }
val saveResult = tools.getValue("memory_save").tool.invoke( val saveResult = tools.getValue("memory_save").invoke(
"""{"category":"preference","content":"prefers tabs over spaces"}""", """{"category":"preference","content":"prefers tabs over spaces"}""",
) )
assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult") assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult")
assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult") assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult")
val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""") val listResult = tools.getValue("memory_list").invoke("""{"limit":10}""")
assertTrue(listResult.contains("prefers tabs over spaces"), assertTrue(listResult.contains("prefers tabs over spaces"),
"list returned: $listResult") "list returned: $listResult")
val readResult = tools.getValue("memory_read").tool.invoke( val readResult = tools.getValue("memory_read").invoke(
"""{"query":"tabs","top_k":3}""", """{"query":"tabs","top_k":3}""",
) )
assertTrue(readResult.contains("prefers tabs over spaces"), assertTrue(readResult.contains("prefers tabs over spaces"),
"read returned: $readResult") "read returned: $readResult")
val deleteResult = tools.getValue("memory_delete").tool.invoke( val deleteResult = tools.getValue("memory_delete").invoke(
Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m -> Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m ->
"""{"id":"${m.groupValues[1]}"}""" """{"id":"${m.groupValues[1]}"}"""
} ?: error("save did not return id"), } ?: error("save did not return id"),
@@ -7,6 +7,8 @@ import java.io.File
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.skills.DiskSkillStore import pw.binom.agentik.skills.DiskSkillStore
import pw.binom.agentik.skill.mining.SkillSaveTool
import pw.binom.agentik.skill.mining.SkillDeleteTool
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
@@ -5,7 +5,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertSame import kotlin.test.assertSame
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.toolsets.NamedTool
import pw.binom.agentik.mcp.bridge.McpConfig import pw.binom.agentik.mcp.bridge.McpConfig
import pw.binom.agentik.mcp.bridge.McpRegistry import pw.binom.agentik.mcp.bridge.McpRegistry
@@ -16,28 +15,26 @@ class McpRegistryTest {
val registry = McpRegistry.fromConfig(McpConfig.empty()) val registry = McpRegistry.fromConfig(McpConfig.empty())
assertEquals(0, registry.allTools.size) assertEquals(0, registry.allTools.size)
assertEquals(0, registry.connectedServerCount) assertEquals(0, registry.connectedServerCount)
assertEquals(emptyList(), registry.namedTools)
registry.close() registry.close()
} }
@Test @Test
fun `empty servers list produces empty registry`() { fun `empty servers list produces empty registry`() {
val registry = McpRegistry(servers = emptyList()) val registry = McpRegistry(servers = emptyList())
assertEquals(0, registry.namedTools.size)
assertEquals(0, registry.allTools.size) assertEquals(0, registry.allTools.size)
registry.close() registry.close()
} }
@Test @Test
fun `NamedTool holds name and tool reference`() { fun `LiteTool name is exposed`() {
val noop: LiteTool = object : LiteTool { val noop: LiteTool = object : LiteTool {
override val name: String = "server__echo"
override fun describe(): String = "{}" override fun describe(): String = "{}"
override fun invoke(arguments: String): String = "" override fun invoke(arguments: String): String = ""
} }
val nt = NamedTool(name = "server__echo", tool = noop) assertEquals("server__echo", noop.name)
assertEquals("server__echo", nt.name) assertSame(noop, noop)
assertSame(noop, nt.tool) assertEquals("{}", noop.describe())
assertEquals("{}", nt.tool.describe())
} }
@Test @Test
@@ -8,7 +8,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.storage.ksqlite.KsqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
import kotlin.test.Test import kotlin.test.Test
@@ -22,11 +22,11 @@ import kotlin.time.Instant
class PersistenceTest { class PersistenceTest {
private lateinit var stores: KsqliteStores private lateinit var stores: SqliteStores
@BeforeTest @BeforeTest
fun setup() { fun setup() {
stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}") stores = SqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
} }
@AfterTest @AfterTest
@@ -116,6 +116,11 @@ class PersistenceTest {
val removed = stores.conversations.delete("c1") val removed = stores.conversations.delete("c1")
assertTrue(removed) assertTrue(removed)
assertNull(stores.conversations.get("c1")) assertNull(stores.conversations.get("c1"))
// conversations.delete больше не каскадит — это делает владелец
// lifecycle (ChatAgent). Здесь проверяем, что каждый store делает
// только свою таблицу чистой при ручном вызове.
stores.messages.clear("c1")
stores.workingMemory.clear("c1")
assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()) assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList())
assertEquals(emptyList(), stores.workingMemory.list("c1")) assertEquals(emptyList(), stores.workingMemory.list("c1"))
} }

Some files were not shown because too many files have changed in this diff Show More