Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s

This commit is contained in:
2026-09-23 03:48:38 +03:00
parent 84f5fd84f3
commit e447525059
105 changed files with 2732 additions and 1978 deletions
+6 -5
View File
@@ -18,8 +18,9 @@ agentik/
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
├── storage-inmemory/ in-memory реализация для тестов и Android
├── storage-sqlite/ SQLite реализация для production
│ (исторический, см. journal-api / context-api / reflection-api ниже)
├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
├── ~~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-md`](memory-md/README.md) — Hermes-style файл.
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
- [`:storage-core`](storage-core/README.md) — контракт storage.
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
- [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
- ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
- ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
## Где смотреть версии
-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(":reflection-api"))
api(project(":context-api"))
api(project(":agent-api"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
api(libs.litert.tools.kotlinx.serialization)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
@@ -1,5 +1,6 @@
package pw.binom.agentik.toolsets
import kotlinx.serialization.Serializable
import pw.binom.litert.LiteTool
/**
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
*/
class DisableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
val tool: LiteTool = liteToolSuspend<DisableArgs>(
name = NAME,
description = "Deactivate a toolset by name. Its tools become unavailable.",
) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
internal suspend fun invoke(args: DisableArgs): String {
val name = args.name
val toolset = registry.findByName(name)
if (toolset != null) {
// Единообразный ответ независимо от текущего состояния.
registry.deactivate(name)
return "Toolset '$name' deactivated."
}
// Неизвестный — перечисляем активные (что можно деактивировать)
val actives = registry.activeNames()
return if (actives.isEmpty()) {
"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 {
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
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.Serializable
import pw.binom.litert.LiteTool
/**
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
*/
class EnableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
val tool: LiteTool = liteToolSuspend<EnableArgs>(
name = NAME,
description = "Activate a toolset by name to access its tools.",
) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
internal suspend fun invoke(args: EnableArgs): String {
val name = args.name
val toolset = registry.findByName(name)
if (toolset != null) {
val wasActive = registry.isActive(name)
registry.activate(name)
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
}
// Неизвестный — перечисляем доступные к активации (inactives)
val inactives = registry.inactiveNames()
return if (inactives.isEmpty()) {
"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 {
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 pw.binom.litert.LiteTool
import pw.binom.litert.tools.kotlinx.serialization.liteTool
/**
* Адаптер из suspend-handler'а в синхронный [LiteTool].
* Обёртка из suspend-handler'а в синхронный [LiteTool].
*
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
*
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
* который генерит JSON-схему из `@Serializable Args` через
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
* тулов, чьи описания не получается выразить через `Args` (например, динамические
* JSON Schema, приходящие со стороны).
*
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
*/
internal class SyncLiteTool(
override val name: String,
private val describeJson: String,
private val handler: suspend (String) -> String,
) : LiteTool {
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
}
/**
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
* как API-точка чтобы внешний код не зависел от internal-имени класса.
* Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
* JSON-схема генерится автоматически из `Args.descriptor`,
* а сырая строка аргументов десериализуется в типизированный [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 =
SyncLiteTool(describeJson, handler)
inline fun <reified Args> liteToolSuspend(
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 {
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 invoke(arguments: String) = "ok"
}
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = disable.invoke("""{"name":"media"}""")
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
assertEquals("Toolset 'media' deactivated.", r)
assertEquals(false, reg.isActive("media"))
}
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
val r = disable.invoke("""{"name":"media"}""")
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
assertEquals("Toolset 'media' deactivated.", r)
}
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
))
reg.activate("a")
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)
}
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf(
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)
}
@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 {
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 invoke(arguments: String) = "ok"
}
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
val (enable, reg) = harness(listOf(
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(true, reg.isActive("media"))
}
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = enable.invoke("""{"name":"media"}""")
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' already active.", r)
}
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
ToolsetContribution("b", "y", 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)
}
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
ToolsetContribution("a", "x", emptyList()),
))
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)
}
@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 {
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 invoke(arguments: String) = response
}
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
class ToolsetRegistryTest {
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 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-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
"storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
"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); переиспользуемое ядро.",
"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)."
+1 -1
View File
@@ -444,7 +444,7 @@ agent.deleteConversation(conv.id) // → DELETE /conversations/
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:storage-ksqlite` как образец.
`:journal-ksqlite` как образец.
```kotlin
import pw.binom.agentik.journal.MutableConversationStore
+1 -1
View File
@@ -10,7 +10,7 @@ plugins {
// агента.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite).
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin {
jvmToolchain(21)
@@ -17,15 +17,9 @@ import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
*
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
* из `:storage-ksqlite`, но:
* - лежит в собственном модуле `:context-ksqlite`;
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
* теперь главный класс — `ContextStore`); сами типы строк
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
* Единственный владелец таблицы `working_memory` в проекте. Используется
* напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
*
* ## Lifecycle соединения
*
@@ -12,7 +12,7 @@ import pw.binom.db.ksqlite.SQLiteConnection
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
+6 -2
View File
@@ -6,7 +6,7 @@ kotlinx-io = "0.8.0"
ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0"
litert = "8"
litert = "13"
sqldelight = "2.3.2"
shadow = "8.3.5"
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-openai = { module = "pw.binom.litert:litert-openai", 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-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" }
# --- Логирование: 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" }
+1 -1
View File
@@ -9,7 +9,7 @@ plugins {
// собственных ksqlite-модулях.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite).
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin {
jvmToolchain(21)
@@ -14,15 +14,15 @@ import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
*
* Миграция завершена: это ЕДИНСТВЕННЫЙ класс для message-таблицы. Параллельная
* копия `:storage-ksqlite/KsqliteMessageStore.kt` удалена вместе со своим
* тестом (consumer `KsqliteStores.assemble()` уже мигрировал на этот класс).
* Единственный класс для message-таблицы. Используется напрямую через
* `:journal-ksqlite` зависимость; bundle'ом собирает
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
*
* ## Lifecycle соединения
*
* Три формы конструктора с разной семантикой владения:
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
* его в [close]. Для shared-connection bundles (`KsqliteStores.assemble`),
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
* где один connection используется многими store'ами и закрывается bundle'ом.
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
* его в [close].
@@ -63,7 +63,7 @@ class KsqliteJournalStore private constructor(
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`KsqliteStores.assemble`), где один
* shared-connection bundles (`SqliteStores.assemble`), где один
* connection используется многими store'ами и закрывается bundle'ом.
*/
constructor(connection: SQLiteConnection) : this(
@@ -24,7 +24,7 @@ import kotlinx.coroutines.withContext
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
* store НЕ закрывает его в [close] (используется shared-connection
* bundle'ом `KsqliteStores.assemble`).
* bundle'ом `SqliteStores.assemble`).
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
@@ -47,7 +47,7 @@ class KsqliteMutableConversationStore private constructor(
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`KsqliteStores.assemble`).
* shared-connection bundles (`SqliteStores.assemble`).
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
@@ -10,10 +10,8 @@ import kotlin.time.Instant
/**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
*
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
* `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
* не в каком-то внешнем общем модуле.
*/
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
@@ -82,7 +82,7 @@ object Schema {
* user_version как gate перестал работать — два модуля ставят его в 1,
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. `KsqliteStores.open()` в `:storage-ksqlite`).
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
*/
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
@@ -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()
}
}
}
@@ -0,0 +1,142 @@
package pw.binom.agentik.journal.ksqlite
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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Тесты на Schema.migrate() в `:journal-ksqlite`:
* - fresh DB → создаются `conversation` + `message` + индексы
* (`idx_conv_updated`, `idx_msg_conv`);
* - уже мигрированная БД → migrate() идемпотентен (no-op);
* - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
* migrate() приводит её в боевое состояние;
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
* делают full-scan по message.
*
* `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
* в `pw.binom.agentik.reflection.ksqlite`.
*/
class SchemaMigrationTest {
@Test
fun `fresh DB gets conversation and message tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
for (table in listOf(
Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE,
)) {
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
}
for (index in listOf(
Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_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("mig-idem-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
// повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn)
Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
} finally {
conn.close()
}
}
@Test
fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
val convStore = KsqliteMutableConversationStore(conn)
val msgStore = KsqliteJournalStore(conn)
try {
convStore.upsert(
ConversationRecord(
id = "c1", title = "t", isTemporal = false,
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
)
)
msgStore.append(
MessageRecord.UserMessage(
id = "m1", conversationId = "c1",
content = listOf(Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
)
)
val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size)
assertEquals("m1", got[0].id)
} finally {
convStore.close()
msgStore.close()
}
}
@Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
val cols = indexColumns(conn, Schema.IDX_MSG_CONV)
assertEquals(listOf(Schema.COL_CONVERSATION_ID, Schema.COL_CREATED_AT), cols)
} 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() }
}
}
private fun indexColumns(conn: SQLiteConnection, indexName: String): List<String> {
// PRAGMA index_info возвращает одну строку на колонку индекса
// (seqno, cid, name). Параметризовать через `?` нельзя — собираем
// строку (name — контролируемая константа, не user input).
val cols = mutableListOf<String>()
conn.prepare("PRAGMA index_info($indexName)").use { stmt ->
stmt.executeQuery().use { rs ->
while (rs.next()) {
rs.getText(2)?.let(cols::add)
}
}
}
return cols
}
}
+16 -7
View File
@@ -2,9 +2,8 @@
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
//
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
// доступные JVM target'у.
// KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
// `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins {
@@ -15,6 +14,14 @@ kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
@@ -24,16 +31,18 @@ kotlin {
api(project(":context-api"))
api(project(":skills"))
api(libs.litert.api)
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
// jvm/native variant из общего артефакта).
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
}
jvmMain.dependencies {
// mu.KotlinLogging — JVM-only, для SkillMiner'а
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.litert.tools.kotlinx.serialization)
implementation(project(":memory-md"))
}
}
}
@@ -29,7 +29,7 @@ class LlmReflector(
private val llm: LiteLlm,
val maxTurns: Int = 6,
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,
) {
/**
@@ -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.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.test.runTest
@@ -8,7 +8,7 @@ import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
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.assertEquals
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.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 когда те снова включатся.
//
// Зависимости:
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
// - litert.api для LiteTool контракта
// - litert.api для LiteTool контракта (в v9 у LiteTool появилось поле name,
// NamedTool-обёртка из :agent-toolsets больше не нужна)
// - MCP SDK (JVM-only)
// - Ktor client (для StreamableHttpClientTransport)
// - kotlinx-serialization для парсинга конфига
@@ -22,7 +22,9 @@ kotlin {
}
dependencies {
implementation(project(":agent-toolsets"))
// :agent-api — отсюда Component / ToolProvider; McpBridgeComponent
// реализует Component и подсовывает MCP-тулы через ToolProvider.
api(project(":agent-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 pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap
import pw.binom.agentik.toolsets.NamedTool
/**
* Реестр подключённых MCP-серверов.
@@ -60,11 +59,6 @@ class McpRegistry(
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
@@ -181,13 +175,13 @@ internal class McpLiteToolAdapter(
private val client: Client,
) : LiteTool {
internal val fullName: String = "${serverName}__${tool.name}"
override val name: String = "${serverName}__${tool.name}"
override fun describe(): String =
buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName)
put("name", name)
put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema())
}.toString()
@@ -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)
* или архивируются через [Curator]-подобный процесс, но не редактируются.
+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.MutableSharedFlow
@@ -11,7 +11,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import kotlin.time.Instant
/**
* Thread-safe List-импл [ReflectionStore].
* Thread-safe Map-импл [ReflectionStore].
*
* `weakSpots` хранятся как List<String> в самой структуре — JSON-сериализация
* делается на уровне 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.ReflectionEvent
@@ -209,7 +209,7 @@ class KsqliteReflectionStore private constructor(
)
}
/** Hand-rolled JSON-encode/decode для List<String> — синхронизировать с :storage-sqlite. */
/** Hand-rolled JSON-encode/decode для List<String>. */
internal fun encodeStringArray(items: List<String>): String = buildString {
append('[')
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 pw.binom.agentik.reflection.Reflection
@@ -8,19 +8,18 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class KsqliteReflectionStoreTest {
private lateinit var stores: KsqliteStores
private lateinit var store: KsqliteReflectionStore
@BeforeTest
fun setup() {
stores = KsqliteStores.inMemory("refl-${kotlin.random.Random.nextLong()}")
store = KsqliteReflectionStore.memory("refl-${kotlin.random.Random.nextLong()}")
}
@AfterTest
fun tearDown() = stores.close()
fun tearDown() = store.close()
private fun refl(
id: String,
@@ -41,8 +40,8 @@ class KsqliteReflectionStoreTest {
@Test
fun testInsertAndGetRoundtrip() = runTest {
stores.reflections.insert(refl("r1"))
val got = stores.reflections.get("r1")
store.insert(refl("r1"))
val got = store.get("r1")
assertNotNull(got)
assertEquals("r1", got.id)
assertEquals(listOf("weak1", "weak2"), got.weakSpots)
@@ -50,45 +49,45 @@ class KsqliteReflectionStoreTest {
@Test
fun testGetReturnsNullForMissing() = runTest {
assertNull(stores.reflections.get("nope"))
assertNull(store.get("nope"))
}
@Test
fun testListRecentSortedByCreatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", ts = t0))
stores.reflections.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("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 = stores.reflections.listRecent(limit = 3)
val recent = store.listRecent(limit = 3)
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
}
@Test
fun testListForConversationFilters() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", conv = "c-1"))
stores.reflections.insert(refl("r2", conv = "c-2"))
stores.reflections.insert(refl("r3", conv = "c-1"))
val forC1 = stores.reflections.listForConversation("c-1", limit = 10)
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 t0 = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", ts = t0))
stores.reflections.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
stores.reflections.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
assertNull(stores.reflections.get("r1"))
assertNotNull(stores.reflections.get("r2"))
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 {
assertEquals(0, stores.reflections.count())
stores.reflections.insert(refl("r1"))
stores.reflections.insert(refl("r2"))
assertEquals(2, stores.reflections.count())
assertEquals(0, store.count())
store.insert(refl("r1"))
store.insert(refl("r2"))
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() }
}
}
}
+26 -24
View File
@@ -34,7 +34,7 @@ include(":standalone")
include(":llm-tools")
// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены
// из :standalone — MCP не специфичен для standalone'а, это generic мост
// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool).
// между MCP-SDK и LiteTool.
include(":mcp-bridge")
// Собственный протокол agentik. Пока в нём пилим, потом вынесем.
include(":proto")
@@ -61,18 +61,15 @@ include(":memory-md")
// для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART
// работает через Java 11 base classes (scalar fallback).
include(":memory-vector")
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore /
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации —
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred).
// In-memory реализация store'ов из :journal-api и :context-api. KMP,
// IO-зависимостей. Используется в тестах (быстрый setup, без JDBC) и будет
// использоваться в Android-сборке (JVector/SQLite не подходят для ART out-of-box).
// Интерфейсы хранилища и разговорной истории. Контракты распределены по
// доменам: :journal-api (MutableJournalStore/MutableConversationStore),
// :context-api (ContextStore), :reflection-api (ReflectionStore).
// Реализации — :journal-ksqlite/:context-ksqlite/:reflection-ksqlite (ksqlite)
// и :journal-inmemory/:reflection-inmemory (in-memory).
//include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids)
// и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids)
// Модуль удалён с диска.
include(":reflection-api")
//include(":message-log-api")
//include(":message-log-ksqlite")
// Новые API-модули трёх сущностей (canonical имена):
// - :journal-api — append-only audit log (бывший :message-log-api)
// - :context-api — то что видит LLM (бывший :working-memory-api)
@@ -97,7 +94,6 @@ include(":journal-inmemory")
include(":memory-md-vector")
//include(":event-store-in-memory")
//include(":working-memory-api")
include(":storage-inmemory")
// SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only
// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
@@ -105,24 +101,20 @@ include(":storage-inmemory")
// Linux — собираются локально на macOS. Пока покрывает только EventStore;
// остальные store'ы мигрируют после стабилизации ksqlite и реального
// использования на Android-агенте.
include(":storage-ksqlite")
// ksqlite-реализация :context-api (ContextStore / working_memory table).
// Минимальный модуль: только таблица `working_memory` + 2 индекса.
// Параллельно существует :storage-ksqlite/KsqliteWorkingMemoryStore.kt —
// миграция consumers'ов по чуть-чуть, отдельно.
include(":context-ksqlite")
// ksqlite-реализация :journal-api (JournalStore / message table).
// Минимальный модуль: только таблица `message` + 1 индекс
// `(conversation_id, created_at)`. Параллельно существует
// :storage-ksqlite/KsqliteMessageStore.kt — миграция consumers'ов
// по чуть-чуть, отдельно.
// ksqlite-реализация :journal-api (JournalStore / message table,
// ConversationStore / conversation table). Минимальный модуль:
// таблицы `message` + `conversation` + индексы.
include(":journal-ksqlite")
// KMP-реализация EventStore через ksqlite (https://github.com/caffeine-mgn/ksqlite).
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
// на KMP-таргетах (JVM + linuxX64 + mingwX64). Apple targets auto-disabled на
// Linux — собираются локально на macOS. На этом этапе покрывает только EventStore;
// остальные store'ы мигрируют после стабилизации ksqlite (>=0.2) и реального
// использования на Android-агенте.
// ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
// Минимальный модуль: только таблица `reflection` + 2 индекса.
include(":reflection-ksqlite")
// in-memory реализация :reflection-api (ReflectionStore). Используется в
// тестах, dev-режиме и embedded-сценариях. Чистый KMP (9 таргетов),
// никакого I/O.
include(":reflection-inmemory")
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
@@ -138,3 +130,13 @@ include(":vector-index-jvector")
// 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, слушающий BackgroundEventBus и добывающий новые скилы
// через SkillMiner на Closing/Compaction.
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.Dispatchers
@@ -35,7 +35,7 @@ class SkillMiner(
private val llm: LiteLlm,
val maxTurns: Int = 30,
private val maxTokens: Int = 1536,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
/**
* Mine по последним [turns] с учётом текущего каталога [existing].
@@ -0,0 +1,154 @@
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.standalone.agent.BackgroundEventBus]
* на этот 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
@@ -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.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.JsonObject
@@ -22,6 +22,8 @@ class SkillReadTool(
private val catalog: SkillCatalog,
) : LiteTool {
override val name: String get() = NAME
override fun describe(): String = buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который
// принимает 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.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,
) : LiteTool {
override val name: String = "skill_save"
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val parsed = parseArgs(arguments)
?: return error("invalid arguments: $arguments")
?: throw IllegalStateException("invalid arguments: $arguments")
val (name, description, body) = parsed
return try {
store.upsert(SkillFile(name = name, description = description, body = body))
"""{"ok":true,"name":"${escape(name)}"}"""
} catch (e: Exception) {
error(e.message ?: "upsert failed")
throw IllegalStateException(e.message ?: "upsert failed")
}
}
private fun parseArgs(arguments: String): Triple<String, String, String>? {
// Минимальный парсер JSON-объекта — вытаскиваем три строковых поля.
// Полагаемся на порядок ключей в агенте: name, description, body.
val name = extractString(arguments, "name") ?: return null
val description = extractString(arguments, "description") ?: return null
val body = extractString(arguments, "body") ?: return null
@@ -75,22 +71,23 @@ internal class SkillSaveTool(
/**
* Тул `skill_delete(name)` — архивирует скил.
*/
internal class SkillDeleteTool(
class SkillDeleteTool(
private val store: SkillStore,
) : LiteTool {
override val name: String = "skill_delete"
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val name = extractString(arguments, "name")
?: return error("missing 'name' in arguments: $arguments")
?: throw IllegalStateException("missing 'name' in arguments: $arguments")
return try {
val removed = store.remove(name)
if (removed) {
"""{"ok":true,"archived":"${escape(name)}"}"""
} else {
error("skill '$name' not found")
throw IllegalStateException("skill '$name' not found")
}
} catch (e: Exception) {
error(e.message ?: "delete failed")
throw IllegalStateException(e.message ?: "delete failed")
}
}
@@ -113,8 +110,7 @@ internal class SkillDeleteTool(
/**
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
* JSON простой (плоский объект с известным набором ключей), как в
* MemoryToolsFactory.
* JSON простой (плоский объект с известным набором ключей).
*/
internal fun extractString(json: String, key: String): String? {
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 pw.binom.agentik.memory.ConversationTurn
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
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.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiningParser
import pw.binom.agentik.skill.mining.SkillMiningParser
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.SkillFile
@@ -19,7 +19,6 @@ class SkillReadToolTest {
@Test
fun describeIsFlatOpenApiSchema() {
val json = tool.describe()
// Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
assertTrue("\"name\"" in json)
assertTrue(SkillReadTool.NAME in json)
assertTrue("parameters" in json)
+17 -2
View File
@@ -22,7 +22,7 @@ val skipVectorMemory: Boolean =
// :standalone — единственный entry-point проекта, JVM-only по факту (один
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
// commonMain + commonTest — даже зависимости, которые формально JVM-only
// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут
// (ktor-server-cio, litert-openai, ...). KMP-плагин тут
// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`,
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
kotlin {
@@ -64,21 +64,36 @@ kotlin {
}
// 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.
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,
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
implementation(project(":agent-toolsets"))
implementation(project(":llm-tools"))
// Skill-подсистема как Component: тулы + SkillMiningComponent.
implementation(project(":skill-mining"))
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
implementation(project(":mcp-bridge"))
// litert-kmp: контракт (commonMain)
api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
implementation(libs.litert.tools.kotlinx.serialization)
// litert-openai: JVM-реализация
implementation(libs.litert.openai)
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
@@ -13,7 +13,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.standalone.agent.ChatConversation
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.skills.SkillStore
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.ModelDownloader
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 pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
/**
* standalone-контейнер agentik:
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
@@ -199,7 +199,7 @@ private fun runServer() {
}
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)
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
@@ -214,9 +214,9 @@ private fun runServer() {
// Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы,
// которые модель забыла сохранить через `skill_save`. Работает только когда
// есть куда писать (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) {
pw.binom.agentik.llm.tools.SkillMiner(
pw.binom.agentik.skill.mining.SkillMiner(
llm = llm,
maxTurns = config.skillMining.maxTurns,
)
@@ -369,7 +369,6 @@ private fun runServer() {
reflectionStore = sqliteStores.reflections,
llm = llm,
llmConfig = config.llm,
tools = mcpRegistry.namedTools,
skills = skills,
skillStore = skillStore,
memoryStore = memorySystem?.store,
@@ -382,7 +381,7 @@ private fun runServer() {
recentReflections = recentReflections,
reflector = reflector,
skillMiner = skillMiner,
)
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
// чтобы debug-эндпоинты могли его триггерить вручную.
@@ -17,7 +17,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
import java.util.concurrent.atomic.AtomicLong
/**
@@ -1,12 +1,25 @@
package pw.binom.agentik.standalone.agent
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.merge
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.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.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystemGuidance
@@ -18,8 +31,10 @@ import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.renderSystemPromptSection
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.agent.memory.MemoryComponent
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.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
@@ -30,16 +45,17 @@ import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.toolsets.DisableToolsetTool
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.ToolsetComponent
import pw.binom.agentik.toolsets.ToolsetContribution
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.agentik.toolsets.ToolsetRegistry
import pw.binom.litert.LiteLlm
/**
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
* [LiteLlm] (универсальный LLM-контракт).
* Stateful [MutableAgent] (= [ProtoAgent] + install/uninstall/component extension)
* на базе SQLite (история + working memory) и [LiteLlm] (универсальный LLM-контракт).
*
* Создаёт [ChatConversation] — те самые stateful диалоги, которые
* хранят свой собственный LiteConversation в ОЗУ (для скорости) и
@@ -47,6 +63,13 @@ import pw.binom.litert.LiteLlm
*
* Один [LiteLlm] шарится между всеми беседами агента.
*
* **External тулы (MCP, etc.)** приходят не через constructor, а через
* [Component.install]: типичный путь — `McpBridgeComponent` из `:mcp-bridge`
* добавляет [ToolProvider] в [toolProviders] при [install]. На каждом
* `send()` или создании беседы агент вызывает [collectTools] чтобы
* собрать свежий список тулов (= внешние через [toolProviders] + встроенные).
* Это позволяет добавлять/убирать MCP-серверы в runtime без рестарта.
*
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для
* загрузки полного текста навыка по требованию.
@@ -57,7 +80,7 @@ import pw.binom.litert.LiteLlm
* `ChatConversation` настраивается prefetch (контекст в начале user-сообщения)
* и post-turn reviewer (извлечение фактов после каждого хода).
*/
class ChatAgent(
internal class ChatAgent(
override val id: String,
private val mutableConversationStore: MutableConversationStore,
private val messageStore: MutableJournalStore,
@@ -65,7 +88,6 @@ class ChatAgent(
private val reflectionStore: ReflectionStore,
private val llm: LiteLlm,
private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = emptyList(),
private val skills: SkillCatalog = SkillCatalog.EMPTY,
/**
* Хранилище скилов для self-improvement (запись/архивирование). Если
@@ -76,6 +98,8 @@ class ChatAgent(
private val skillStore: pw.binom.agentik.skills.SkillStore? = null,
private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null,
private val memoryPrefetcher: MemoryPrefetcher? = null,
internal val _backgroundEvents: BackgroundEventBus = BackgroundEventBus(),
internal val agentScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
private val memoryReviewer: MemoryReviewer? = null,
/**
* Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
@@ -117,83 +141,230 @@ class ChatAgent(
* секция в system prompt НЕ добавляется (полная невидимость per A1-α).
*/
private val toolsets: List<ToolsetContribution> = emptyList(),
) : ProtoAgent, AutoCloseable {
) : MutableAgent, AutoCloseable {
/**
* Test-only: регистрирует дополнительный tool в общий [toolsByName] ПОСЛЕ
* создания ChatAgent. Используется в тестах `interrupt mid-tool` для
* симуляции долгого tool-вызова, который можно прервать через interrupt().
* В production этот API НЕ используется — тулы статичны через конструктор.
* Провайдеры внешних/динамических тулов (MCP, etc.). Заполняются
* компонентами через [install]. На каждом [collectTools] агент
* спрашивает у всех провайдеров их список тулов.
*
* Изменение этого списка (через [install]/[uninstall]) сразу же отражается
* на tool-dispatch следующего turn'а любой беседы.
*/
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) {
toolsByName[name] = NamedTool(name = name, tool = tool)
}
override val toolProviders: MutableList<ToolProvider> = mutableListOf()
/**
* Реестр активных тулсетов — один на агента (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)
/** Список активных тулов (включая 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, если задан) → база → секция навыков → секция памяти
* → секция self-reflection (слабые места) → секция тулсетов (если они заданы).
* Именно он сидируется в working memory и передаётся в [ChatConversation].
* → секция self-reflection (слабые места). Секция тулсетов теперь приходит
* динамически от [ToolsetComponent] через [systemProviders].
*/
private val systemPrompt: String = buildSystemPrompt(
private val baseSystemPrompt: String = buildSystemPrompt(
base = llmConfig.systemPrompt,
skills = skills,
memoryEnabled = memoryStore != null,
soulBody = soulBody,
reflections = recentReflections,
toolsetSection = if (toolsets.isNotEmpty()) {
SystemPromptToolsetSection.render(
active = emptyList(), // все по умолчанию неактивны
inactive = toolsets,
)
} else null,
)
/**
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные:
* `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть
* [skillStore]), `memory_*` (если подключена память),
* `enable_toolset`/`disable_toolset` (если заданы [toolsets]).
* MCP-тулы префиксованы `server__`, так что коллизий нет.
* Тестовый инжектор: позволяет `ChatAgentTest` подсунуть тул, который
* скриптованная LLM будет вызывать. В production-конфигурации всегда пуст —
* инструменты приходят через [toolProviders] (компоненты, [install]).
*/
private val allTools: List<NamedTool> = buildList {
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 testTools: MutableList<LiteTool> = mutableListOf()
/**
* Собирает **актуальный** список тулов для диспетчеризации:
* внешние из [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.
}
private val toolsByName: MutableMap<String, NamedTool> = allTools.associateBy { it.name }.toMutableMap()
/**
* Тестовый хук: регистрирует [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 = toolsByName[name]
val t = collectTools().firstOrNull { it.name == name }
?: error("unknown tool: $name")
t.tool.invoke(args)
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
/**
* Runtime system prompt = база + динамические секции от `systemProviders`.
* Вызывается на каждом turn'е (через [systemPromptResolver]). Пустые секции
* пропускаются; компонент, у которого `getSection` вернул пустую строку,
* не появляется в промте.
*/
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
merge(
_backgroundEvents.lifecycleEvents.filterIsInstance<ConversationLifecycleEvent.Closing>()
.map { SkillMiningEvent.ConversationClosing(it.conversationId) },
_backgroundEvents.compactionEvents.filterIsInstance<CompactionEvent.Triggered>()
.map { SkillMiningEvent.ConversationCompacted(it.conversationId) },
)
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 —
* это всё, что нужно [SkillMiner] / [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
}
/**
* Единый канал всех событий агента — bounded tail с auto-TTL.
*
@@ -278,7 +449,9 @@ class ChatAgent(
eventStore = eventStore,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
@@ -293,6 +466,10 @@ class ChatAgent(
runBlocking {
liveLock.withLock { live[conv.id] = conv }
}
// Оповещаем ConversationAware компоненты (например, SkillMiningComponent)
// о новом разговоре — они могут подписаться на events и запустить
// фоновые задачи.
attachConversation(conv.asHandle())
runBlocking {
eventStore.append(
CommonEvent.Agent(
@@ -309,11 +486,13 @@ class ChatAgent(
val rec = mutableConversationStore.get(id) ?: return null
return newConversation(rec).also {
liveLock.withLock { live[id] = it }
attachConversation(it.asHandle())
}
}
override suspend fun deleteConversation(id: String): Boolean {
val conv = liveLock.withLock { live.remove(id) }
conv?.let { detachConversation(it.asHandle()) }
conv?.close()
val ok = mutableConversationStore.delete(id)
if (ok) {
@@ -344,7 +523,9 @@ class ChatAgent(
eventStore = eventStore,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
@@ -361,9 +542,17 @@ class ChatAgent(
runBlocking {
liveLock.withLock {
live.values.forEach { it.close() }
live.values.forEach { detachConversation(it.asHandle()) }
live.clear()
}
}
// Снимаем компоненты в обратном порядке, чтобы каждый успел закрыть свои ресурсы.
// Копия `components` нужна на случай если uninstall модифицирует список.
val snapshot = components.toList()
for (i in snapshot.indices.reversed()) {
runCatching { snapshot[i].uninstall(this) }
}
components.clear()
runCatching { llm.close() }
}
@@ -380,28 +569,17 @@ class ChatAgent(
*/
internal fun buildSystemPrompt(
base: String,
skills: SkillCatalog,
memoryEnabled: Boolean,
soulBody: String? = null,
reflections: List<Reflection> = emptyList(),
toolsetSection: String? = null,
): String {
val trimmedBase = base.trimEnd()
val skillsSection = skills.renderSystemPromptSection()
val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection
val withMemory = if (memoryEnabled) withSkills + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else withSkills
val withMemory = if (memoryEnabled) trimmedBase + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else trimmedBase
val withReflections = if (reflections.isNotEmpty()) {
withMemory + "\n\n" + renderReflectionsSection(reflections)
} else {
withMemory
}
val withToolsets = if (!toolsetSection.isNullOrBlank()) {
withReflections + "\n\n" + toolsetSection
} else {
withReflections
}
val trimmedSoul = soulBody?.trim()
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withToolsets else withToolsets
return withReflections
}
/**
@@ -34,7 +34,7 @@ internal class CompactionCoordinator(
private val log = KotlinLogging.logger {}
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)
@@ -147,7 +147,14 @@ internal class CompactionCoordinator(
val dropFrom = toCompact.first().orderIdx
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(
systemText = systemText,
@@ -189,7 +196,29 @@ internal class CompactionCoordinator(
): LiteConversation {
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 ->
val isRelevant = row.entry is WorkingMemoryEntry.User
|| row.entry is WorkingMemoryEntry.Assistant
@@ -199,7 +228,7 @@ internal class CompactionCoordinator(
}
.mapNotNull { row ->
val e: WorkingMemoryEntry = row.entry
val msg: LiteMessage? = when (e) {
when (e) {
is WorkingMemoryEntry.User -> LiteMessage(
LiteRole.USER,
applyContextPrefix(e.content.toLiteContents(), e.context),
@@ -217,18 +246,7 @@ internal class CompactionCoordinator(
)
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 {
@@ -12,9 +12,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import mu.KotlinLogging
@@ -47,9 +45,8 @@ import pw.binom.litert.LiteToolCall
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Instant
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.llm.tools.ContextCompactor
import pw.binom.agentik.toolsets.NamedTool
class ConversationLoop(
record: ConversationRecord,
@@ -59,8 +56,30 @@ class ConversationLoop(
private val reflectionStore: ReflectionStore?,
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
private val llm: LiteLlm,
/**
* Базовый системный промпт, который задаётся беседе при создании.
* В runtime может перетираться через [systemPromptResolver] перед каждым
* `sendStreamContents` (LiteRT v12 mutable setters сами делают no-op,
* если значение эквивалентно).
*/
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 memoryPrefetcher: MemoryPrefetcher? = null,
private val memoryReviewer: MemoryReviewer? = null,
@@ -93,7 +112,7 @@ class ConversationLoop(
/** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */
private val backgroundEvents = BackgroundEventBus()
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
private val toolsByName: MutableMap<String, LiteTool> = tools.associateBy { it.name }.toMutableMap()
private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher)
@@ -118,7 +137,6 @@ class ConversationLoop(
toolsByName = toolsByName,
toolsetDispatch = toolsetDispatch,
newId = ::newId,
encodeArgsJson = ::encodeArgsJson,
now = ::now,
)
@@ -237,10 +255,6 @@ class ConversationLoop(
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) {
val wasInterruptedAtEntry = interrupted.get()
if (!state.isTemporal) {
@@ -275,7 +289,7 @@ class ConversationLoop(
val conv = try {
compactor.getOrCreateLiteConversation(
systemPrompt = systemPrompt,
systemPrompt = systemPromptResolver(),
excludeUserSourceId = if (state.isTemporal) null else userRecord.id,
)
} catch (e: Throwable) {
@@ -307,6 +321,10 @@ class ConversationLoop(
val collectedCalls = mutableListOf<LiteToolCall>()
if (pendingParts != null) {
val lc = state.liteConvRef.get() ?: return
// Hot-swap перед каждым sendStreamContents — v12 setter no-op
// если значение эквивалентно текущему (см. LiteConversation.systemInstruction).
lc.systemInstruction = systemPromptResolver()
lc.tools = toolsResolver()
try {
lc.sendStreamContents(pendingParts).collect { delta ->
if (delta.text.isNotEmpty()) {
@@ -359,6 +377,10 @@ class ConversationLoop(
try {
val collectedPostTool = mutableListOf<LiteToolCall>()
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 ->
if (followUp.text.isNotEmpty()) {
reply.append(followUp.text)
@@ -492,28 +514,6 @@ class ConversationLoop(
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 {
private const val MAX_TOOL_LOOPS = 16
}
@@ -3,12 +3,12 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineScope
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteTool
import java.util.concurrent.atomic.AtomicReference
import pw.binom.agentik.toolsets.NamedTool
internal class ConversationState(
initialRecord: ConversationRecord,
val tools: List<NamedTool>,
val tools: List<LiteTool>,
val agentScope: CoroutineScope,
) {
@Volatile
@@ -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,15 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.litert.LiteToolCall
import pw.binom.litert.LiteTool
import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
internal class ToolDispatcher(
private val state: ConversationState,
private val messageStore: MutableJournalStore,
private val events: ConversationEvents,
private val backgroundEvents: BackgroundEventBus,
private val toolsByName: MutableMap<String, NamedTool>,
private val toolsByName: MutableMap<String, LiteTool>,
private val toolsetDispatch: ToolsetDispatchPolicy?,
private val newId: (String) -> String,
private val encodeArgsJson: (Map<String, Any?>) -> String,
private val now: () -> Instant,
) {
private val log = KotlinLogging.logger {}
@@ -32,14 +30,11 @@ internal class ToolDispatcher(
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 {
val callId = newId("tc")
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 startMs = System.currentTimeMillis()
@@ -65,7 +60,7 @@ internal class ToolDispatcher(
log.warn { "tool '${call.name}' requested but not registered" }
"[tool not found: ${call.name}]"
} else {
t.tool.invoke(argsJson)
t.invoke(argsJson)
}
} else {
val d = toolsetDispatch
@@ -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
import kotlinx.serialization.json.Json
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 kotlinx.serialization.Serializable
import pw.binom.agentik.memory.DefaultMemoryTools
import pw.binom.agentik.memory.MemoryCategory
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.MemoryStore
import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.toolsets.liteToolSuspend
import pw.binom.litert.LiteTool
import java.util.UUID
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 = Json { encodeDefaults = true }
private val MemoryResponseJson = kotlinx.serialization.json.Json { encodeDefaults = true }
private val ToolInvocationJson = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
isLenient = false
coerceInputValues = true
explicitNulls = false
}
/**
* Превращает [NewMemoryNote] (из reviewer'а) в полноценную [MemoryNote],
@@ -48,169 +44,89 @@ fun materializeReviewNote(n: NewMemoryNote, conversationId: String? = null): Mem
/** Сериализация результатов поиска для LLM — компактный JSON. */
fun serializeSearchResults(results: List<MemorySearchResult>): String {
val items = results.map { r ->
buildJsonObject {
put("id", JsonPrimitive(r.note.id))
put("category", JsonPrimitive(r.note.category.id))
put("score", JsonPrimitive(r.score))
put("content", JsonPrimitive(truncate(r.note.content, 300)))
kotlinx.serialization.json.buildJsonObject {
put("id", kotlinx.serialization.json.JsonPrimitive(r.note.id))
put("category", kotlinx.serialization.json.JsonPrimitive(r.note.category.id))
put("score", kotlinx.serialization.json.JsonPrimitive(r.score))
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. */
fun serializeNotes(notes: List<MemoryNote>): String {
val items = notes.map { n ->
buildJsonObject {
put("id", JsonPrimitive(n.id))
put("category", JsonPrimitive(n.category.id))
put("content", JsonPrimitive(truncate(n.content, 300)))
put("created_at", JsonPrimitive(n.createdAt.toString()))
kotlinx.serialization.json.buildJsonObject {
put("id", kotlinx.serialization.json.JsonPrimitive(n.id))
put("category", kotlinx.serialization.json.JsonPrimitive(n.category.id))
put("content", kotlinx.serialization.json.JsonPrimitive(truncate(n.content, 300)))
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 =
if (s.length <= max) s else s.substring(0, max) + "…"
private fun parseArgs(s: String): JsonObject =
runCatching { MemoryToolsJson.parseToJsonElement(s).jsonObject }.getOrElse { JsonObject(emptyMap()) }
@Serializable
internal data class SaveArgs(val category: String, val content: String)
private fun objString(s: String, key: String): String? {
val v = parseArgs(s)[key] ?: return null
return if (v is JsonPrimitive && v.isString) v.content else v.toString().trim('"')
}
@Serializable
internal data class ReadArgs(val query: String, val top_k: Int = 5, val category: String? = null)
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)
/**
* Flat OpenAPI-спецификация тула (name/description/parameters) — формат,
* который напрямую принимает 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) } })
}
@Serializable
internal data class DeleteArgs(val id: String)
/** memory_save(category, content) → upsert. */
internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_save",
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"),
),
),
internal fun saveTool(store: MemoryStore): LiteTool = liteToolSuspend<SaveArgs>(
name = "memory_save",
description = DefaultMemoryTools.save.description,
) { args ->
val category = objString(args, "category")?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
?: return@SyncLiteTool """{"error":"category required"}"""
val content = objString(args, "content")
?: return@SyncLiteTool """{"error":"content required"}"""
if (content.isBlank()) return@SyncLiteTool """{"error":"content is blank"}"""
val note = materializeReviewNote(NewMemoryNote(category, content))
val category = runCatching { MemoryCategory.fromId(args.category) }.getOrNull()
?: return@liteToolSuspend """{"error":"category required"}"""
if (args.content.isBlank()) return@liteToolSuspend """{"error":"content is blank"}"""
val note = materializeReviewNote(NewMemoryNote(category, args.content))
store.upsert(note)
"""{"ok":true,"id":"${note.id}"}"""
}
/** memory_read(query, top_k?, category?) → search. */
internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_read",
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"),
),
),
internal fun readTool(store: MemoryStore): LiteTool = liteToolSuspend<ReadArgs>(
name = "memory_read",
description = DefaultMemoryTools.read.description,
) { args ->
val query = objString(args, "query") ?: return@SyncLiteTool """{"error":"query required"}"""
val topK = objInt(args, "top_k") ?: 5
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
val category = args.category?.takeIf { it.isNotBlank() }
?.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)
}
/** memory_list(category?, limit?) → list. */
internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_list",
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")
}
},
),
),
internal fun listTool(store: MemoryStore): LiteTool = liteToolSuspend<ListArgs>(
name = "memory_list",
description = DefaultMemoryTools.list.description,
) { args ->
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
val category = args.category?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
val limit = objInt(args, "limit") ?: 20
val notes = store.list(category = category, limit = limit)
val notes = store.list(category = category, limit = args.limit)
serializeNotes(notes)
}
/** memory_delete(id) → delete. */
internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_delete",
DefaultMemoryTools.delete.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("id") {
put("type", "string")
put("description", "memory note id (mem-...)")
}
},
required = listOf("id"),
),
),
internal fun deleteTool(store: MemoryStore): LiteTool = liteToolSuspend<DeleteArgs>(
name = "memory_delete",
description = DefaultMemoryTools.delete.description,
) { args ->
val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}"""
if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}"""
if (store.delete(args.id)) """{"ok":true,"deleted":"${args.id}"}""" else """{"ok":false,"missing":"${args.id}"}"""
}
@@ -1,34 +1,23 @@
package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.MemoryStore
import pw.binom.litert.LiteTool
import pw.binom.agentik.toolsets.NamedTool
/**
* Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete)
* поверх конкретного [MemoryStore]. Каждый инструмент возвращает JSON-строку,
* совместимую с тем, что отдают остальные NamedTool'ы в проекте.
* совместимую с тем, что отдают остальные LiteTool'ы в проекте.
*
* Реализация каждого тула — типизированный `@Serializable Args` + suspend handler —
* через `pw.binom.agentik.toolsets.liteToolSuspend` (сам построен на
* `pw.binom.litert.tools.kotlinx.serialization.liteTool`).
*/
object MemoryToolsFactory {
fun create(store: MemoryStore): List<NamedTool> = listOf(
NamedTool("memory_save", saveTool(store)),
NamedTool("memory_read", readTool(store)),
NamedTool("memory_list", listTool(store)),
NamedTool("memory_delete", deleteTool(store)),
fun create(store: MemoryStore): List<LiteTool> = listOf(
saveTool(store),
readTool(store),
listTool(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()
}
@@ -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.assertTrue
import kotlin.time.Instant
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.reflection.Reflection
import pw.binom.agentik.memory.MemorySystemGuidance
class ChatAgentReflectionTest {
@@ -13,9 +13,7 @@ class ChatAgentReflectionTest {
fun `buildSystemPrompt omits section when reflections empty`() {
val prompt = buildSystemPrompt(
base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = false,
soulBody = null,
reflections = emptyList(),
)
assertFalse(prompt.contains("Self-reflection"))
@@ -35,9 +33,7 @@ class ChatAgentReflectionTest {
)
val prompt = buildSystemPrompt(
base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = false,
soulBody = null,
reflections = listOf(r),
)
assertTrue(prompt.contains("Self-reflection"))
@@ -48,7 +44,10 @@ class ChatAgentReflectionTest {
}
@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(
id = "r1",
conversationId = null,
@@ -60,15 +59,15 @@ class ChatAgentReflectionTest {
)
val prompt = buildSystemPrompt(
base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = true,
soulBody = "I am a soul",
reflections = listOf(r),
)
// soul первый, reflection последняя
val soulIdx = prompt.indexOf("I am a soul")
assertTrue(prompt.contains("base"), "base missing: $prompt")
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")
assertTrue(soulIdx >= 0 && reflIdx >= 0)
assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection")
assertTrue(baseIdx < memIdx && memIdx < reflIdx, "expected base < memory < reflection, got $baseIdx/$memIdx/$reflIdx")
}
}
@@ -10,13 +10,14 @@ import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.proto.Content
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.SkillFile
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.journal.MessageRecord
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.LiteConversation
import pw.binom.litert.LiteConversationConfig
@@ -37,16 +38,15 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
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
@BeforeTest
fun setup() {
sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
sqliteStores = SqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
}
@@ -56,25 +56,28 @@ class ChatAgentTest {
}
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,
tools: List<NamedTool> = emptyList(),
skills: SkillCatalog = SkillCatalog.EMPTY,
): ChatAgent = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
llm = llm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
tools = tools,
skills = skills,
)
tools: List<LiteTool> = emptyList(),
skillStore: pw.binom.agentik.skills.SkillStore? = null,
): ChatAgent {
val agent = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
skillStore = skillStore,
llm = llm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
)
tools.forEach { agent.addTestTool(it) }
return agent
}
@Test
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
@@ -93,14 +96,13 @@ class ChatAgentTest {
@Test
fun `skills are appended to the system prompt passed to LLM`() = runTest {
// Skills добавляются в system prompt на лету при сборке ChatAgent.
// Проверяем это через то, что увидит LLM — systemInstruction в
// LiteConversationConfig (а не через working_memory, куда теперь
// ничего про system prompt не пишется).
val skills = SkillCatalog(
// Скиллы теперь приходят через SkillComponent → SkillSystemProvider →
// buildRuntimeSystemPrompt() при каждом turn'е. Проверяем через
// systemInstruction, который увидит LLM.
val skillStore = InMemorySkillStore(
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)
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
@@ -116,10 +118,10 @@ class ChatAgentTest {
@Test
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")),
)
val agent = newAgent(skills = skills)
val agent = newAgent(skillStore = skillStore)
val conv = agent.createConversation(temp = false)
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
@@ -419,14 +421,15 @@ class ChatAgentTest {
// LLM скриптован: tool call.
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 {
override val name: String = "echo_tool"
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
}
agent.registerToolForTest("echo_tool", echoTool)
agent.addTestTool(echoTool)
// Подписываемся ДО send — SharedFlow без replay
val events = mutableListOf<ProtoEvent>()
@@ -472,7 +475,7 @@ class ChatAgentTest {
// Поднимаем file-backed БД, создаём temp-беседу
sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -492,7 +495,7 @@ class ChatAgentTest {
// Переоткрываем БД — temp-беседа не должна пережить рестарт
sqliteStores.close()
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -514,7 +517,7 @@ class ChatAgentTest {
fun `non-temp conversation persists across agent instances`() = runTest {
sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -532,7 +535,7 @@ class ChatAgentTest {
val id = conv.id
sqliteStores.close()
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -564,11 +567,12 @@ class ChatAgentTest {
@Test
fun `tool-call loop executes registered tool and feeds result back`() = runTest {
val echoTool = object : LiteTool {
override val name: String = "echo"
override fun describe(): String = """{"type":"function","function":{"name":"echo"}}"""
override fun invoke(arguments: String): String = "echoed: $arguments"
}
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)
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
@@ -624,6 +628,12 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
return object : LiteConversation {
private val hist = mutableListOf<LiteMessage>()
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 sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
hist.add(LiteMessage(LiteRole.USER, contents))
@@ -632,7 +642,7 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
LiteDelta(
text = "",
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 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.litert.LiteLlm
import pw.binom.litert.LiteTool
@@ -20,6 +20,7 @@ import kotlin.test.assertTrue
class ChatAgentToolsetsTest {
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 invoke(arguments: String) = response
}
@@ -39,8 +40,8 @@ class ChatAgentToolsetsTest {
private fun newAgent(
toolsets: List<ToolsetContribution> = emptyList(),
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> {
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
): Pair<ChatAgent, pw.binom.agentik.standalone.persistence.SqliteStores> {
val sqliteStores = SqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
val agent = ChatAgent(
id = "test-agent",
mutableConversationStore = sqliteStores.conversations,
@@ -183,9 +184,18 @@ class ChatAgentToolsetsTest {
// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов.
internal fun ChatConversation.systemPromptForTest(): String {
// Через рефлексию достаём private val systemPrompt.
val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true }
return f.get(this) as String
// v12+: секция "## Toolsets" приходит от ToolsetSystemProvider'а в
// buildRuntimeSystemPrompt() (динамически). Получаем промт агента
// через тот же путь, что и 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 {
@@ -11,7 +11,7 @@ import pw.binom.agentik.memory.md.KeywordMdReviewer
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
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.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
@@ -40,12 +40,12 @@ import pw.binom.agentik.llm.tools.SummaryTurn
*/
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
@BeforeTest
fun setup() {
sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
sqliteStores = SqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
}
@@ -18,7 +18,7 @@ import pw.binom.litert.LiteToolCall
internal class FakeLiteLlm : LiteLlm {
sealed class 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"
@@ -67,6 +67,8 @@ internal class FakeLiteConversation(
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)))
@@ -104,6 +106,11 @@ internal class FakeLiteConversation(
}
}
}
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
@@ -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.LlmConfig
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.SummaryTurn
import kotlin.test.AfterTest
@@ -49,13 +49,13 @@ import kotlin.time.Instant
*/
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 root: Path
@BeforeTest
fun setup() {
sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
sqliteStores = SqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
SystemFileSystem.createDirectories(root, mustCreate = true)
@@ -173,7 +173,7 @@ class MemoryWiringTest {
assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names)
// Каждый tool описывается валидной JSON-схемой:
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()
}
@@ -182,23 +182,23 @@ class MemoryWiringTest {
fun `memory_save tool round-trips a note through the store`() = runBlocking {
val system = openMdMemorySystem(root)
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"}""",
)
assertTrue(saveResult.contains("\"ok\":true"), "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"),
"list returned: $listResult")
val readResult = tools.getValue("memory_read").tool.invoke(
val readResult = tools.getValue("memory_read").invoke(
"""{"query":"tabs","top_k":3}""",
)
assertTrue(readResult.contains("prefers tabs over spaces"),
"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 ->
"""{"id":"${m.groupValues[1]}"}"""
} ?: error("save did not return id"),
@@ -7,6 +7,8 @@ import java.io.File
import kotlin.uuid.Uuid
import kotlinx.coroutines.runBlocking
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.SkillFile
import pw.binom.agentik.skills.SkillStore
@@ -5,7 +5,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
import pw.binom.agentik.toolsets.NamedTool
import pw.binom.agentik.mcp.bridge.McpConfig
import pw.binom.agentik.mcp.bridge.McpRegistry
@@ -16,28 +15,26 @@ class McpRegistryTest {
val registry = McpRegistry.fromConfig(McpConfig.empty())
assertEquals(0, registry.allTools.size)
assertEquals(0, registry.connectedServerCount)
assertEquals(emptyList(), registry.namedTools)
registry.close()
}
@Test
fun `empty servers list produces empty registry`() {
val registry = McpRegistry(servers = emptyList())
assertEquals(0, registry.namedTools.size)
assertEquals(0, registry.allTools.size)
registry.close()
}
@Test
fun `NamedTool holds name and tool reference`() {
fun `LiteTool name is exposed`() {
val noop: LiteTool = object : LiteTool {
override val name: String = "server__echo"
override fun describe(): String = "{}"
override fun invoke(arguments: String): String = ""
}
val nt = NamedTool(name = "server__echo", tool = noop)
assertEquals("server__echo", nt.name)
assertSame(noop, nt.tool)
assertEquals("{}", nt.tool.describe())
assertEquals("server__echo", noop.name)
assertSame(noop, noop)
assertEquals("{}", noop.describe())
}
@Test
@@ -8,7 +8,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry
import kotlinx.coroutines.flow.toList
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.BeforeTest
import kotlin.test.Test
@@ -22,11 +22,11 @@ import kotlin.time.Instant
class PersistenceTest {
private lateinit var stores: KsqliteStores
private lateinit var stores: SqliteStores
@BeforeTest
fun setup() {
stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
stores = SqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
}
@AfterTest
-55
View File
@@ -1,55 +0,0 @@
# `:storage-inmemory` — in-memory реализация `:storage-core` (KMP, jvm + native)
## Что это
In-memory реализация `MessageStore / WorkingMemoryStore /
ConversationStore / ReflectionStore`. Все структуры держит в
`ConcurrentHashMap` + `MutableList`, фолотится на RAM
(никаких файлов).
Решает: дешёвая тестовая среда без поднятия SQLite. Позволяет
прогонять `ChatAgentTest` за миллисекунды и держать сценарии
детерминированными.
## Где используется
- В тестах `:standalone` (`AbstractITTest`).
- В Android-имплементации (in-memory + Android-database микс).
- В любых юнит-тестах на агенте.
## Как подключить
```kotlin
commonMain.dependencies {
api("pw.binom.agentik:storage-inmemory:0.1.0")
api("pw.binom.agentik:storage-core:0.1.0")
}
val storage = InMemoryStorageSystem()
val messages: MessageStore = storage.messages
val working: WorkingMemoryStore = storage.working
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-storage-inmemory`.
## Тесты
```
./gradlew :storage-inmemory:allTests
```
Покрывают (через общие contract-tests): round-trip, paged flow,
concurrent appends, working-memory replay, очистку.
## Чего здесь НЕТ
- Никакого persistence. Перезапуск процесса — данные пропали.
Это нормально для тестов и Android in-memory.
## Текущий статус
Используется продакшеном (в режиме тестов). Контракт-совместима
с `:storage-sqlite` 1:1 — переключение `AGENTIK_STORAGE_BACKEND=memory`
в `:standalone`.
-33
View File
@@ -1,33 +0,0 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
// Зеркалит набор :storage-core — in-memory импл, чтобы тесты и embedded
// (Android) запуски не зависели от SQLite/JDBC. Совпадает по семантике
// с :storage-sqlite (тред-безопасность через Mutex, AutoCloseable).
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":journal-api"))
api(project(":journal-inmemory"))
api(project(":reflection-api"))
api(project(":context-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,49 +0,0 @@
package pw.binom.agentik.storage.inmemory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import kotlin.time.Instant
/**
* Thread-safe append-only лог сообщений в памяти.
*
* Хранит все записи в одном `List<MessageRecord>`, индексированном
* `conversationId`. Подходит для unit-тестов и ephemeral runtime (например,
* CLI-сессии или in-memory demo), где не нужен долгоживущий persistence.
*
* В отличие от ksqlite-импла, не требует native SQLite — работает в любом
* KMP-таргете (включая iOS/native).
*/
class InMemoryMessageStore : MutableJournalStore {
private val byConv: MutableMap<String, MutableList<MessageRecord>> = mutableMapOf()
private val mutex = Mutex()
override suspend fun append(record: MessageRecord) {
mutex.withLock {
val list = byConv.getOrPut(record.conversationId) { mutableListOf() }
list.add(record)
}
}
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord> {
mutex.withLock {
val all = byConv[conversationId].orEmpty()
val filtered = all.filter { it.createdAt > after }
.sortedBy { it.createdAt }
return filtered.drop(offset).take(limit)
}
}
override suspend fun clear(conversationId: String) {
mutex.withLock {
byConv.remove(conversationId)
}
}
override fun close() {
// no-op
}
}
@@ -1,37 +0,0 @@
package pw.binom.agentik.storage.inmemory
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
import pw.binom.agentik.reflection.ReflectionStore
import kotlin.time.Clock
/**
* Фабрика готового набора in-memory store'ов.
*
* Удобно для:
* - тестов (быстрая инициализация, не нужен JDBC driver);
* - embedded-сценариев (Android ART, edge-узлы);
* - dry-run / preview, где SQLite не нужен.
*
* Семантика полностью совпадает с SQLite-имплами (`:storage-sqlite`).
*
* Возвращает четыре store'а по отдельности — caller пробрасывает их
* туда, где нужны (раньше был `StorageBundle`, от него отказались).
*/
object InMemoryStorage {
data class Bundle(
val conversationStore: MutableConversationStore,
val messageStore: MutableJournalStore,
val workingMemoryStore: ContextStore,
val reflectionStore: ReflectionStore,
)
fun create(clock: Clock = Clock.System): Bundle = Bundle(
conversationStore = InMemoryMutableConversationStore(clock),
messageStore = InMemoryMessageStore(),
workingMemoryStore = InMemoryWorkingMemoryStore(),
reflectionStore = InMemoryReflectionStore(),
)
}
@@ -1,90 +0,0 @@
package pw.binom.agentik.storage.inmemory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.journal.Ids
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.WorkingMemoryRow
import pw.binom.agentik.context.ContextStore
import kotlin.time.Instant
/**
* Thread-safe List-импл [WorkingMemoryStore].
*
* Append дает `order_idx = max(existing.order_idx) + 1` (или 0 если пусто).
* `compact` атомарно удаляет строки в диапазоне `[dropFromOrderIdx, +∞)`
* и опционально вставляет [WorkingMemoryEntry.Summary] в хвост.
*
* Семантика 1:1 с SQLite-имплом (см. `:storage-sqlite` после commit 3).
*/
class InMemoryWorkingMemoryStore : ContextStore {
private val byConv: MutableMap<String, MutableList<WorkingMemoryRow>> = mutableMapOf()
private val mutex = Mutex()
private suspend fun nextOrderIdx(conversationId: String): Long = mutex.withLock {
(byConv[conversationId]?.maxOfOrNull { it.orderIdx } ?: -1L) + 1L
}
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) {
mutex.withLock {
val rows = byConv.getOrPut(conversationId) { mutableListOf() }
val nextIdx = (rows.maxOfOrNull { it.orderIdx } ?: -1L) + 1L
rows.add(
WorkingMemoryRow(
id = Ids.new("wm"),
conversationId = conversationId,
orderIdx = nextIdx,
sourceMessageId = entry.sourceMessageId,
entry = entry,
createdAt = now,
)
)
}
}
override suspend fun list(conversationId: String): List<WorkingMemoryRow> {
mutex.withLock {
return byConv[conversationId].orEmpty()
.sortedBy { it.orderIdx }
.toList()
}
}
override suspend fun clear(conversationId: String) {
mutex.withLock {
byConv.remove(conversationId)
}
}
override suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String?,
): Long = mutex.withLock {
val rows = byConv.getOrPut(conversationId) { mutableListOf() }
rows.removeAll { it.orderIdx >= dropFromOrderIdx }
val newMax = rows.maxOfOrNull { it.orderIdx } ?: -1L
return if (summaryText.isNullOrBlank()) {
newMax
} else {
val summary = WorkingMemoryEntry.Summary(text = summaryText)
val newIdx = newMax + 1L
rows.add(
WorkingMemoryRow(
id = Ids.new("wm"),
conversationId = conversationId,
orderIdx = newIdx,
sourceMessageId = null,
entry = summary,
createdAt = kotlin.time.Clock.System.now(),
)
)
newIdx
}
}
override fun close() {
// no-op
}
}
@@ -1,89 +0,0 @@
package pw.binom.agentik.storage.inmemory
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageRecord
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
class InMemoryMessageStoreTest {
@Test
fun `append and listFlow returns inserted records in createdAt order`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val u = MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0)
val a = MessageRecord.AssistantMessage("a1", "c1", listOf(Content.Text("hello")), t0.plus(kotlin.time.Duration.parse("PT1S")), null)
store.append(u)
store.append(a)
val all = store.listFlow("c1", Instant.DISTANT_PAST).toList()
assertEquals(listOf("u1", "a1"), all.map { it.id })
}
@Test
fun `list filters by after and supports offset+limit`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 0 until 5) {
store.append(
MessageRecord.UserMessage(
"u$i",
"c1",
listOf(Content.Text("msg-$i")),
t0.plus(kotlin.time.Duration.parse("PT${i}S")),
)
)
}
// after=t0+1s должны видеть только msg-2..4 (т.е. u2,u3,u4)
val after = t0.plus(kotlin.time.Duration.parse("PT1S"))
val page = store.list("c1", after = after, offset = 0, limit = 10)
assertEquals(listOf("u2", "u3", "u4"), page.map { it.id })
val page2 = store.list("c1", after = after, offset = 1, limit = 10)
assertEquals(listOf("u3", "u4"), page2.map { it.id })
}
@Test
fun `listFlow returns empty for unknown conversation`() = runTest {
val store = InMemoryMessageStore()
assertTrue(store.listFlow("none", Instant.DISTANT_PAST).toList().isEmpty())
}
@Test
fun `isolates conversations - listFlow returns only requested conv`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append(MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0))
store.append(MessageRecord.UserMessage("u2", "c2", listOf(Content.Text("hello")), t0))
assertEquals(listOf("u1"), store.listFlow("c1", Instant.DISTANT_PAST).toList().map { it.id })
assertEquals(listOf("u2"), store.listFlow("c2", Instant.DISTANT_PAST).toList().map { it.id })
}
@Test
fun `close is idempotent and does nothing`() {
val store = InMemoryMessageStore()
store.close()
store.close()
}
@Test
fun `append with null context and null tokens is supported`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val msg = MessageRecord.UserMessage(
id = "u1",
conversationId = "c1",
content = listOf(Content.Text("hi")),
createdAt = t0,
context = null,
)
store.append(msg)
val got = store.listFlow("c1", Instant.DISTANT_PAST).first() as MessageRecord.UserMessage
assertNull(got.context)
}
}
@@ -1,118 +0,0 @@
package pw.binom.agentik.storage.inmemory
import pw.binom.agentik.reflection.Reflection
import pw.binom.agentik.reflection.ReflectionEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.yield
import kotlinx.coroutines.test.runTest
class InMemoryReflectionStoreTest {
private fun sample(id: String, convId: String?, at: Instant, score: Int = 4) = Reflection(
id = id,
conversationId = convId,
createdAt = at,
turnsAnalyzed = 5,
score = score,
summary = "ok",
weakSpots = listOf("weakness-1"),
)
@Test
fun `insert and get roundtrip preserves all fields`() = runTest {
val store = InMemoryReflectionStore()
val r = sample("r1", "c1", Instant.parse("2026-09-15T10:00:00Z"))
store.insert(r)
assertEquals(r, store.get("r1"))
}
@Test
fun `get returns null for missing id`() = runTest {
val store = InMemoryReflectionStore()
assertNull(store.get("nope"))
}
@Test
fun `listRecent returns most-recent first up to limit`() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 0 until 5) {
store.insert(sample("r$i", "c$i", t0.plus(kotlin.time.Duration.parse("PT${i}S"))))
}
val top3 = store.listRecent(limit = 3)
assertEquals(listOf("r4", "r3", "r2"), top3.map { it.id })
}
@Test
fun `listForConversation returns only entries for that conversation most-recent first`() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.insert(sample("r1", "c1", t0))
store.insert(sample("r2", "c2", t0.plus(kotlin.time.Duration.parse("PT1S"))))
store.insert(sample("r3", "c1", t0.plus(kotlin.time.Duration.parse("PT2S"))))
store.insert(sample("r4", "c1", t0.plus(kotlin.time.Duration.parse("PT3S"))))
val c1List = store.listForConversation("c1", limit = 10)
assertEquals(listOf("r4", "r3", "r1"), c1List.map { it.id })
}
@Test
fun `listForConversation with null conversationId returns global ones`() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.insert(sample("r1", null, t0))
store.insert(sample("r2", "c1", t0.plus(kotlin.time.Duration.parse("PT1S"))))
val global = store.listForConversation("", limit = 10)
assertEquals(listOf("r1"), global.map { it.id })
}
@Test
fun `deleteOlderThan removes entries created before cutoff`() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.insert(sample("r-old", "c1", t0))
store.insert(sample("r-old2", "c1", t0.plus(kotlin.time.Duration.parse("PT10S"))))
store.insert(sample("r-new", "c1", t0.plus(kotlin.time.Duration.parse("PT60S"))))
store.deleteOlderThan(cutoff = t0.plus(kotlin.time.Duration.parse("PT30S")))
assertEquals(1, store.count())
assertNotNull(store.get("r-new"))
assertNull(store.get("r-old"))
assertNull(store.get("r-old2"))
}
@Test
fun `count reflects inserts`() = runTest {
val store = InMemoryReflectionStore()
assertEquals(0, store.count())
store.insert(sample("r1", "c1", Instant.parse("2026-09-15T10:00:00Z")))
store.insert(sample("r2", "c1", Instant.parse("2026-09-15T10:01:00Z")))
assertEquals(2, store.count())
}
@Test
fun `events flow emits Created on insert`() = runTest {
val store = InMemoryReflectionStore()
val r = sample("r1", "c1", Instant.parse("2026-09-15T10:00:00Z"))
// SharedFlow не реплеит — запускаем коллектор ДО insert.
val events = store.events()
val deferred = async { events.first() }
yield() // даём коллектору подписаться ДО insert
store.insert(r)
val ev = deferred.await()
assertTrue(ev is ReflectionEvent.Created)
assertEquals("r1", (ev as ReflectionEvent.Created).reflection.id)
}
@Test
fun `close is idempotent and does nothing`() {
val store = InMemoryReflectionStore()
store.close()
store.close()
}
}
@@ -1,106 +0,0 @@
package pw.binom.agentik.storage.inmemory
import pw.binom.agentik.journal.Content
import pw.binom.agentik.context.WorkingMemoryEntry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.test.runTest
class InMemoryWorkingMemoryStoreTest {
@Test
fun `append assigns sequential order_idx starting from 0`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
val rows = store.list("c1")
assertEquals(2, rows.size)
assertEquals(listOf(0L, 1L), rows.map { it.orderIdx })
}
@Test
fun `order_idx continues across conversations independently`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
// c2 должен начать с 0, не продолжать c1
val rows2 = store.list("c2")
assertEquals(1, rows2.size)
assertEquals(0L, rows2[0].orderIdx)
}
@Test
fun `list returns sorted by order_idx ASC`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
// store назначает order_idx = max(existing) + 1, поэтому порядок вставки
// определяет позицию в списке. Сортировка по order_idx даёт ровно
// порядок append'ов.
store.append("c1", WorkingMemoryEntry.User("u0", listOf(Content.Text("a")), null), t0)
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("b")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("c")), null), t0.plus(kotlin.time.Duration.parse("PT2S")))
val rows = store.list("c1")
assertEquals(listOf("u0", "u1", "u2"), rows.map { it.entry.sourceMessageId })
// а createdAt — это переданный параметр, не пересчитывается
assertEquals(listOf(t0, t0.plus(kotlin.time.Duration.parse("PT1S")), t0.plus(kotlin.time.Duration.parse("PT2S"))), rows.map { it.createdAt })
}
@Test
fun `clear removes all rows for a conversation`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
store.clear("c1")
assertEquals(emptyList(), store.list("c1"))
assertEquals(1, store.list("c2").size)
}
@Test
fun `compact without summary drops tail and returns new max`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
// dropFromOrderIdx=1 → удаляет всё >= 1 (то есть только Assistant "a1")
val newMax = store.compact(dropFromOrderIdx = 1, conversationId = "c1", summaryText = null)
assertEquals(0L, newMax)
val rows = store.list("c1")
assertEquals(1, rows.size)
assertEquals("u1", rows[0].entry.sourceMessageId)
}
@Test
fun `compact with summary replaces tail with synthetic Summary row`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("how are you")), null), t0.plus(kotlin.time.Duration.parse("PT3S")))
store.append("c1", WorkingMemoryEntry.Assistant("a2", listOf(Content.Text("fine, thanks"))), t0.plus(kotlin.time.Duration.parse("PT4S")))
// drop tail from idx 2, insert summary
val newMax = store.compact(
dropFromOrderIdx = 2,
conversationId = "c1",
summaryText = "user asked hi and how-are-you, assistant replied",
)
assertEquals(2L, newMax)
val rows = store.list("c1")
assertEquals(3, rows.size)
val summary = rows.last().entry as WorkingMemoryEntry.Summary
assertEquals("user asked hi and how-are-you, assistant replied", summary.text)
assertNull(rows.last().sourceMessageId)
}
@Test
fun `close is idempotent and does nothing`() {
val store = InMemoryWorkingMemoryStore()
store.close()
store.close()
}
}
-54
View File
@@ -1,54 +0,0 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
//
// Цели сборки:
// - jvm() — основная, тесты гоняются здесь (in-memory DB без файла)
// - linuxX64() / mingwX64() — smoke-проверка что KMP реально KMP
// - androidNative* — НЕ включены, нужны NDK headers иначе gradle падает.
// Когда дойдём до Android-агента — добавим.
//
// Apple targets (macos*, ios*, tvos*, watchos*) невозможно собрать на Linux —
// Kotlin Multiplatform plugin auto-disables их, так что даже не пытаемся.
//
// ksqlite 0.1.2 опубликован в Maven Central (см. settings.gradle.kts —
// mavenCentral() уже в списке репозиториев), так что CI его видит.
// Предыдущая зависимость `pw.binom.db:ksqlite:0.1.1-SNAPSHOT` жила только
// в локальном ~/.m2 и ломала CI.
// text-embedding-kmp с v4 (2026-09-21) опубликован в caffeine Nexus.
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :message-store-api / :working-memory-api / :message-log-api объявлены
// как api-зависимости здесь, в commonMain — без этого commonMain
// не скомпилируется (KsqliteConversationStore, KsqliteMessageStore
// и т.д. используют их типы в commonMain). У них самих есть Apple
// targets (macosX64/Arm64, iosX64/Arm64/SimulatorArm64, linuxArm64),
// так что KMP-метаданные корректно резолвятся для всех таргетов.
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json)
api(project(":journal-api"))
api(project(":journal-ksqlite"))
api(project(":reflection-api"))
api(project(":context-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,55 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore as JournalKsqliteConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Фабрика 4 store'ов поверх ksqlite.
*
* Lifecycle: открывает [SQLiteConnection] и возвращает bundle из 4 store'ов.
* Каждый store сам прогоняет свою схему в конструкторе
* (`Schema.migrate(connection)` — idempotent `CREATE TABLE IF NOT EXISTS`), так
* что явных вызовов миграции в bundle'е нет. Caller ДОЛЖЕН вызвать [close]
* при завершении.
*
* @param path путь к .db файлу, либо URI для in-memory/shared-cache.
*/
class KsqliteStores internal constructor(
val connection: SQLiteConnection,
val conversations: MutableConversationStore,
val messages: MutableJournalStore,
val workingMemory: ContextStore,
val reflections: ReflectionStore,
) : AutoCloseable {
override fun close() {
// Закрытие в правильном порядке: зависимые → владелец connection.
conversations.close()
messages.close()
workingMemory.close()
reflections.close()
connection.close()
}
companion object {
fun open(path: String): KsqliteStores =
assemble(SQLiteConnection.open(path))
fun inMemory(name: String = "agentik-test"): KsqliteStores =
assemble(SQLiteConnection.memory(name))
private fun assemble(conn: SQLiteConnection): KsqliteStores = KsqliteStores(
connection = conn,
conversations = JournalKsqliteConversationStore(conn),
messages = KsqliteJournalStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
}
}
@@ -1,225 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.WorkingMemoryRow
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.journal.Ids
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [ContextStore] для `working_memory`.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteWorkingMemoryStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle).
* - `KsqliteWorkingMemoryStore(path)` — открывает файловое соединение,
* прогоняет [Schema.migrate], закрывает соединение в [close].
* - `KsqliteWorkingMemoryStore.memory(name)` — in-memory, мигрирует,
* закрывает в [close].
*/
class KsqliteWorkingMemoryStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : 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 json = Json { ignoreUnknownKeys = true }
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
// SIGSEGV в StmtHolder.finalize на закрытой connection).
private val insertStmt = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
ORDER BY ${Schema.COL_ORDER_IDX} ASC
""".trimIndent()
)
private val clearStmt = connection.prepare(
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val maxOrderIdxStmt = connection.prepare(
"""
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
""".trimIndent()
)
private val dropFromIdxStmt = connection.prepare(
"""
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
""".trimIndent()
)
private val insertSummaryStmt = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, NULL, ?, ?, ?)
""".trimIndent()
)
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
val newIdx = maxOrderIdx(conversationId) + 1
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, Ids.new("wm"))
insertStmt.bindText(2, conversationId)
insertStmt.bindLong(3, newIdx)
val srcId = entry.sourceMessageId
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
insertStmt.bindText(5, entryKind(entry))
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
insertStmt.bindLong(7, now.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
val out = mutableListOf<WorkingMemoryRow>()
listStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(
WorkingMemoryRow(
id = rs.getText(0)!!,
conversationId = rs.getText(1)!!,
orderIdx = rs.getLong(2)!!,
sourceMessageId = rs.getText(3),
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
)
)
}
}
out
}
}
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String?,
): Long = withContext(Dispatchers.Default) {
mutex.withLock {
var newMax = 0L
val nowMs = Clock.System.now().toEpochMilliseconds()
val summaryId = Ids.new("wm")
connection.exec("BEGIN")
try {
dropFromIdxStmt.reset()
dropFromIdxStmt.clearBindings()
dropFromIdxStmt.bindText(1, conversationId)
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
dropFromIdxStmt.executeUpdate()
if (!summaryText.isNullOrBlank()) {
val afterDelete = maxOrderIdx(conversationId)
val newIdx = afterDelete + 1
insertSummaryStmt.reset()
insertSummaryStmt.clearBindings()
insertSummaryStmt.bindText(1, summaryId)
insertSummaryStmt.bindText(2, conversationId)
insertSummaryStmt.bindLong(3, newIdx)
insertSummaryStmt.bindText(4, "summary")
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
insertSummaryStmt.bindLong(6, nowMs)
insertSummaryStmt.executeUpdate()
newMax = newIdx
} else {
newMax = maxOrderIdx(conversationId)
}
connection.exec("COMMIT")
} catch (t: Throwable) {
runCatching { connection.exec("ROLLBACK") }
throw t
}
newMax
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
maxOrderIdxStmt.close()
dropFromIdxStmt.close()
insertSummaryStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteWorkingMemoryStore =
KsqliteWorkingMemoryStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
private fun maxOrderIdx(conversationId: String): Long {
maxOrderIdxStmt.reset()
maxOrderIdxStmt.clearBindings()
maxOrderIdxStmt.bindText(1, conversationId)
maxOrderIdxStmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0) ?: 0L
}
return 0L
}
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
is WorkingMemoryEntry.User -> "user"
is WorkingMemoryEntry.Assistant -> "assistant"
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
is WorkingMemoryEntry.Summary -> "summary"
}
}
@@ -1,89 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.decodeBodyPayload
import pw.binom.agentik.journal.encodeBodyPayload
import pw.binom.db.ksqlite.SQLiteResultSet
import kotlin.time.Instant
/**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
*
* Копия [pw.binom.agentik.storage.sqlite.SqliteMessageStore] — `private` helpers
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
*/
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
content = record.content,
context = record.context,
)
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
content = record.content,
tokens = record.tokens,
)
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(),
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
)
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
ResultPayload.serializer(),
ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
)
is MessageRecord.Error -> "error" to Json.encodeToString(
ErrorPayload.serializer(),
ErrorPayload(message = record.message, code = record.code),
)
}
internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
val id = getText(0)!!
val convId = getText(1)!!
val kind = getText(2)!!
val payload = getText(3)!!
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
return when (kind) {
"user" -> {
val d = decodeBodyPayload(payload)
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
}
"assistant" -> {
val d = decodeBodyPayload(payload)
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
}
"tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload)
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
}
"tool_result" -> {
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
}
"error" -> {
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
}
else -> error("Unknown message kind in audit log: $kind")
}
}
@kotlinx.serialization.Serializable
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
/**
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
* без поля десериализуются как `null`.
*/
@kotlinx.serialization.Serializable
internal data class ResultPayload(
val toolCallId: String,
val toolName: String? = null,
val result: String?,
)
@kotlinx.serialization.Serializable
internal data class ErrorPayload(val message: String, val code: String?)
@@ -1,116 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда agentik'а,
* которыми владеет `:storage-ksqlite`: `working_memory` + `reflection`.
*
* `conversation` + `message` уехали в `:journal-ksqlite` —
* см. `pw.binom.agentik.journal.ksqlite.Schema`. Управляющий
* [KsqliteStores.open] / [KsqliteStores.inMemory] прогоняет ОБА
* `Schema.migrate(conn)` подряд.
*
* Все DDL/DML в этом модуле ссылаются на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
/** Версия схемы. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблицы ─────
const val TABLE_WORKING_MEMORY = "working_memory"
const val TABLE_REFLECTION = "reflection"
// ───── Колонки working_memory ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_ORDER_IDX = "order_idx"
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Колонки reflection ─────
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_WM_UNIQUE = "idx_wm_unique"
const val IDX_WM_CONV = "idx_wm_conv"
const val IDX_REFLECTION_CREATED = "idx_reflection_created"
const val IDX_REFLECTION_CONV = "idx_reflection_conv"
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Гарантии:
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* уже-мигрированной БД;
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной.
*
**NOTE**: в сплит-мире user_version как gate перестал работать
* (соседний `:journal-ksqlite` тоже ставит user_version=1, и второй
* вызов short-circuit'ит). Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. [KsqliteStores.open] / [KsqliteStores.inMemory]).
*/
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(v1WorkingMemoryDdl)
conn.exec(v1ReflectionDdl)
conn.exec(v1IndexesDdl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
// ───── DDL миграций ─────
// v1 — начальная схема модуля. Пара `CREATE IF NOT EXISTS` →
// миграция idempotent без всяких version-checks.
private val v1WorkingMemoryDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_ORDER_IDX INTEGER NOT NULL,
$COL_SOURCE_MESSAGE_ID TEXT,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
"""
private val v1ReflectionDdl = """
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 '[]'
);
"""
private val v1IndexesDdl = """
-- Working memory: гарантия уникального order_idx внутри conv'а
-- (порядок имеет значение — compaction полагается на монотонность).
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
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);
"""
}

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