7 Commits
11 ... 15

Author SHA1 Message Date
subochev d1b4f897b7 Add GET /conversations/{id}/count endpoint for total/filtered message counts, update JournalStore API, and implement client/server support with tests.
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m4s
2026-09-23 05:43:55 +03:00
subochev a81d92f489 Add count methods to JournalStore API and implementations for message counting per conversation (count(conversationId) and count(conversationId, after)), with supporting tests.
release / Publish KMP libraries → caffeine Nexus (release) Failing after 32s
2026-09-23 05:31:13 +03:00
subochev 3e583ac8ea Relocate CI workflow file from .gitea/workflows/ci.yml to .gitea/ci.yml and update README.md accordingly.
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m16s
2026-09-23 04:47:43 +03:00
subochev f878d1c79b Update deployment host IP in standalone/build.gradle.kts configuration
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:46:37 +03:00
subochev 266ec38c1b Refactor: replace BackgroundScheduler with ReflectionScheduler, migrate to outbox-driven event processing, and remove skill mining logic
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:40:45 +03:00
subochev e447525059 Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s
2026-09-23 03:48:38 +03:00
subochev 84f5fd84f3 remove :storage-ksqlite (conversation/message) and related tests; decouple schema from journal
ci / JVM build + tests (push) Successful in 6m6s
2026-09-22 16:01:05 +03:00
140 changed files with 4492 additions and 2641 deletions
+7 -6
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) — тулы и диспетчер.
## Где смотреть версии
@@ -124,7 +125,7 @@ SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппирован
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
- `.gitea/ci.yml` — PR-build, прогон тестов, проверка
shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
-37
View File
@@ -1,37 +0,0 @@
Status of message-log-api migration:
DONE:
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
- Content.kt (sealed: Text, Image)
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
- MessageStore.kt (interface, TokenStats, MessageEvent)
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
6. Wrote /tmp/run_rename.sh that runs the python script.
PENDING:
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
- Verify build compiles (run standalone tests).
Files that need import updates (per search):
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
- standalone/src/jvmMain/.../ToolDispatcher.kt
- standalone/src/jvmMain/.../ConversationLoop.kt
- standalone/src/jvmTest/.../ChatAgentTest.kt
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
Workaround needed before continuing the migration.
+42
View File
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :proto — read-only Agent interface, который MutableAgent расширяет.
// Через api(), иначе downstream-impl ChatAgent не сможет
// override suspend-методы Agent.
api(project(":proto"))
// :memory-api — typealias ConversationTurn на memory-api одноимённый
// класс, иначе пер-конво компоненты (skill mining, reflection) не
// смогут передать его в SkillMiner.mine() напрямую.
api(project(":memory-api"))
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
api(libs.litert.api)
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
// listener) — оставлен как api, чтобы downstream не забывал объявить.
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.agent
/**
* Нашлёпка поверх [MutableAgent].
*
* Компонент сам регистрирует в агенте свои capability-провайдеры
* при [install] и снимает их при [uninstall]. Агент не знает заранее
* ни о структуре компонента, ни о его провайдерах — это просто
* хук для свободной композиции.
*
* Ktor-style API:
* ```
* val agent = ChatAgent(...)
* .install(SkillComponent(store, miner))
* .install(ReflectionComponent(reflectionStore, reflector))
* .install(MemoryComponent(memorySystem))
* ```
*
* Контракт:
* - [install] **синхронен**: компонент добавляет свои провайдеры в
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
* фоновые корутины — компонент запускает их через свой собственный
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
*/
interface Component {
/**
* Вызывается агентом при [MutableAgent.install].
*
* Типичные действия: добавить [SystemPromptProvider] в
* `agent.systemProviders`, добавить [ToolProvider] в
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
*/
fun install(agent: MutableAgent)
/**
* Вызывается агентом при [MutableAgent.uninstall] или при
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
* которые добавил в [install], и остановить фоновые джобы.
*/
fun uninstall(agent: MutableAgent)
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.agent
/**
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
*
* [MutableAgent] при создании/закрытии разговора вызывает
* [attachConversation] / [detachConversation] на каждом компоненте,
* реализующем этот интерфейс. Внутри компонент хранит
* [ConversationHandle] (или контекст вокруг него) и подписывается на
* нужные события.
*
* Компонент без [ConversationAware] остаётся чисто agent-level — он
* не получает per-conversation хуков.
*/
interface ConversationAware {
fun attachConversation(handle: ConversationHandle)
fun detachConversation(handle: ConversationHandle)
}
/**
* Минимальное окно в разговор, которое компонент видит через
* [ConversationAware]. Содержит только то, что нужно большинству
* per-conversation компонентов:
* - идентификатор (для подписки на события),
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
* - последние N turns (для LlmReflector / SkillMiner).
*
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
* чтобы компонент не лез в чужие обязанности.
*/
interface ConversationHandle : AutoCloseable {
val id: String
val isTemporal: Boolean
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
suspend fun recentTurns(limit: Int): List<ConversationTurn>
override fun close()
}
/**
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
* компоненты (skill mining, reflection) могли передавать его напрямую
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
* конвертации.
*/
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
@@ -0,0 +1,78 @@
package pw.binom.agentik.agent
import pw.binom.agentik.proto.Agent
/**
* Настраиваемая версия [Agent]: расширяет публичный contract агента
* install/uninstall-механикой компонентов ([Component]).
*
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
*
* Контракт:
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
*
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
* позже, в v1 держим KISS.
*/
interface MutableAgent : Agent {
/**
* Провайдеры секций system prompt, регистрируются компонентами через [install].
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
* system prompt конкретной беседы; возвращает `null`, если у него нет
* релевантной секции для данного контекста.
*
* Изменяется **только внутри `Component.install(this)` /
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
* напрямую в список не лезет.
*/
val systemProviders: MutableList<SystemPromptProvider>
/**
* Провайдеры tools, регистрируются компонентами через [install].
* [ToolProvider.tools] вызывается при формировании набора тулов
* для конкретной беседы; компонент решает сам, какие тулы отдавать
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
*/
val toolProviders: MutableList<ToolProvider>
/**
* Устанавливает [component] в агент: `component.install(this)` +
* агент запоминает компонент, чтобы при [close] корректно его снять.
*
* Возвращает `this` — для fluent-цепочек:
* ```
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
* ```
*/
fun install(component: Component): MutableAgent
/**
* Снимает [component]: `component.uninstall(this)` + забывает.
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
*/
fun uninstall(component: Component): MutableAgent
/**
* Оповещает все установленные компоненты, реализующие [ConversationAware],
* о появлении нового разговора. Компонент может подписаться на события,
* запустить фоновые задачи, проиндексировать turns и т.п.
*/
fun attachConversation(handle: ConversationHandle)
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
fun detachConversation(handle: ConversationHandle)
/**
* Освобождает ресурсы агента и снимает все установленные компоненты
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
* первым). Idempotent.
*/
override fun close()
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.agent
/**
* Провайдер одной секции system prompt конкретной беседы.
*
* Вызывается [MutableAgent] при каждом построении system prompt
* (на старте беседы и после значимых изменений контекста). Возвращает
* либо markdown-строку секции (будет вставлена в system prompt в порядке
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
* либо `null`, если у провайдера нет релевантной секции для данного
* контекста (например, skill-каталог пуст).
*
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
* страх и риск, **не** рекомендуется в v1).
*/
fun interface SystemPromptProvider {
/**
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
*
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
* если провайдер хочет делать per-conversation разделение, он может.
*/
fun getSection(conversationId: String): String
}
@@ -0,0 +1,33 @@
package pw.binom.agentik.agent
import pw.binom.litert.LiteTool
/**
* Провайдер набора тулов конкретной беседы.
*
* Вызывается [MutableAgent] при формировании списка тулов, доступных
* модели в данной беседе (на старте и при пересборке после существенных
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
* из `describe()` / `invoke()` того же объекта.
*
* Не-suspend: типичная реализация строит список тулов из in-memory state
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
* к state компонент использует свой собственный scope и кэш.
*
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
* лишним слоем и удалён.
*/
fun interface ToolProvider {
/**
* Возвращает список тулов, доступных модели в беседе [conversationId].
*
* Провайдер может делать per-conversation фильтрацию (например, скрывать
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
* пустой список.
*/
fun getTools(conversationId: String): List<LiteTool>
}
+3
View File
@@ -24,9 +24,12 @@ kotlin {
api(project(":journal-api"))
api(project(":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
@@ -5,6 +5,7 @@ import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.HttpStatusCode
import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Instant
@@ -13,8 +14,11 @@ import kotlin.time.Instant
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
* ходящая в `:server`-фасад.
*
* **Endpoint**: `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
* (см. [pw.binom.agentik.server.journalRoutes]).
* **Endpoints** (см. [pw.binom.agentik.server.journalRoutes]):
* - `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
* → [list]
* - `GET {baseUrl}/journal/conversations/{id}/count` → [count] (total)
* - `GET {baseUrl}/journal/conversations/{id}/count?after=` → [count] (after cursor)
*
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
@@ -54,7 +58,28 @@ internal class HttpJournalStore(
return response.body<List<MessageRecord>>()
}
override suspend fun count(conversationId: String): Long {
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count")
check(response.status == HttpStatusCode.OK) {
"journal.count: server returned ${response.status}"
}
return response.body<CountResponse>().count
}
override suspend fun count(conversationId: String, after: Instant): Long {
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") {
parameter("after", after.toString())
}
check(response.status == HttpStatusCode.OK) {
"journal.count(after): server returned ${response.status}"
}
return response.body<CountResponse>().count
}
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@Serializable
private data class CountResponse(val count: Long)
+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,20 +17,41 @@ import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
*
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
* из `:storage-ksqlite`, но:
* - лежит в собственном модуле `:context-ksqlite`;
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
* теперь главный класс — `ContextStore`); сами типы строк
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
* Единственный владелец таблицы `working_memory` в проекте. Используется
* напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close].
* - `KsqliteContextStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
*/
class KsqliteContextStore(
class KsqliteContextStore private constructor(
private val connection: SQLiteConnection,
private val 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 }
@@ -179,6 +200,15 @@ class KsqliteContextStore(
maxOrderIdxStmt.close()
dropFromIdxStmt.close()
insertSummaryStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteContextStore =
KsqliteContextStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
private fun maxOrderIdx(conversationId: String): Long {
@@ -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
@@ -12,16 +12,9 @@ import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Тесты для [KsqliteContextStore] — точная копия
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
* та же).
*
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
* автономный.
* Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
* SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteContextStoreTest {
@@ -31,7 +24,6 @@ class KsqliteContextStoreTest {
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteContextStore(conn)
}
+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" }
@@ -18,6 +18,28 @@ interface JournalStore : AutoCloseable {
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/**
* Сколько сообщений в диалоге [conversationId] всего.
*
* O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`),
* O(N) на in-memory (size простого list'а с фильтром по conversationId).
* Не зависит от cursor'а [after] — для total-размера диалога.
*/
suspend fun count(conversationId: String): Long
/**
* Сколько сообщений в диалоге [conversationId] создано **позже** [after]
* (строго `createdAt > after`, как и в [list]).
*
* O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для:
* - UI badge "N новых сообщений" — клиент знает последний `lastSeen`,
* сервер говорит `count(convId, after=lastSeen)`;
* - пагинации без получения самих записей: знаем лимит последней страницы,
* надо понять "есть ли ещё";
* - compaction-метрик: «сколько turn'ов осталось после cutoff».
*/
suspend fun count(conversationId: String, after: Instant): Long
/**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для
@@ -14,4 +14,12 @@ package pw.binom.agentik.journal
*/
interface MutableJournalStore : JournalStore {
suspend fun append(record: MessageRecord)
/**
* Удалить все сообщения диалога [conversationId]. Используется
* владельцем lifecycle диалога при его удалении (каскад из
* ChatAgent.deleteConversation). Append-only природа audit log'а
* не нарушается — это bulk-clear, а не редактирование.
*/
suspend fun clear(conversationId: String)
}
@@ -54,9 +54,17 @@ class InMemoryJournalStore : MutableJournalStore {
.toList()
}
/** Сбросить кэш (например, когда диалог удалён). */
suspend fun clear(): Unit = mutex.withLock {
records.clear()
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.removeAll { it.conversationId == conversationId }
}
override suspend fun count(conversationId: String): Long = mutex.withLock {
records.count { it.conversationId == conversationId }.toLong()
}
override suspend fun count(conversationId: String, after: Instant): Long = mutex.withLock {
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
}
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
@@ -29,7 +29,7 @@ class InMemoryMutableConversationStore(
private val clock: Clock = Clock.System,
) : MutableConversationStore {
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf()
private val byId = mutableMapOf<String, ConversationRecord>()
private val mutex = Mutex()
override suspend fun upsert(record: ConversationRecord) {
@@ -66,13 +66,96 @@ class InMemoryJournalStoreTest {
}
@Test
fun `clear empties the cache`() = runTest {
fun `clear empties a conversation only`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "x", t0))
store.append(userMsg("m2", "c2", "y", t0))
assertEquals(2, store.size())
store.clear("c1")
assertEquals(1, store.size())
store.clear()
assertEquals(0, store.size())
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
}
@Test
fun `count returns total per conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
assertEquals(0L, store.count("c1"))
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 1.seconds))
store.append(userMsg("m3", "c2", "c", t0 + 2.seconds))
assertEquals(2L, store.count("c1"))
assertEquals(1L, store.count("c2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun `count is unaffected by clear of another conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..20) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
store.append(userMsg("n1", "c2", "y", t0))
assertEquals(20L, store.count("c1"))
assertEquals(1L, store.count("c2"))
store.clear("c1")
assertEquals(0L, store.count("c1"))
assertEquals(1L, store.count("c2"))
}
@Test
fun `count after cursor excludes earlier messages`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
// strictly > t0
assertEquals(2L, store.count("c1", t0))
// strictly > t0+10s — only the last
assertEquals(1L, store.count("c1", t0 + 10.seconds))
// after last — empty
assertEquals(0L, store.count("c1", t0 + 20.seconds))
// distant past — all
assertEquals(3L, store.count("c1", Instant.DISTANT_PAST))
}
@Test
fun `count after cursor scopes to conversation`() = runTest {
val store = InMemoryJournalStore()
val t = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t))
store.append(userMsg("m2", "c2", "b", t))
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("c1", future))
assertEquals(0L, store.count("c2", future))
assertEquals(1L, store.count("c1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("c2", Instant.DISTANT_PAST))
}
@Test
fun `count agrees with list size`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..15) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
assertEquals(
store.list("c1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("c1"),
)
val cutoff = t0 + 7.seconds
assertEquals(
store.list("c1", cutoff, 0, 1000).size.toLong(),
store.count("c1", cutoff),
)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ plugins {
// собственных ksqlite-модулях.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite).
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin {
jvmToolchain(21)
@@ -14,31 +14,67 @@ import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
*
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore]
* из `:storage-ksqlite`, но:
* - лежит в собственном модуле `:journal-ksqlite`;
* - реализует переименованный [MutableJournalStore] (раньше был
* `MutableMessageStore`, теперь главный класс — `JournalStore` /
* `MutableJournalStore`); сам тип записи [MessageRecord] не
* переименовывался.
* Единственный класс для message-таблицы. Используется напрямую через
* `:journal-ksqlite` зависимость; bundle'ом собирает
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
*
* ## Lifecycle соединения
*
* Три формы конструктора с разной семантикой владения:
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
* где один connection используется многими store'ами и закрывается bundle'ом.
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
* его в [close].
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
* закрывает его в [close].
*
* ## Миграция
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
* прогоняют миграцию одной и той же схемы по очереди.
*
* Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
* (см. [pw.binom.db.ksqlite.StmtHolder]).
*
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
* отдельно.
*/
class KsqliteJournalStore internal constructor(
class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableJournalStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`), где один
* connection используется многими store'ами и закрывается bundle'ом.
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
@@ -64,6 +100,16 @@ class KsqliteJournalStore internal constructor(
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val countAllStmt: SQLitePreparedStatement = connection.prepare(
"SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val countAfterStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
""".trimIndent()
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record)
@@ -94,13 +140,15 @@ class KsqliteJournalStore internal constructor(
listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json))
while (rs.next()) {
out.add(rs.toMessageRecord(json))
}
}
out
}
}
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
@@ -109,9 +157,52 @@ class KsqliteJournalStore internal constructor(
}
}
override suspend fun count(conversationId: String): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAllStmt.reset()
countAllStmt.clearBindings()
countAllStmt.bindText(1, conversationId)
countAllStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override suspend fun count(conversationId: String, after: Instant): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAfterStmt.reset()
countAfterStmt.clearBindings()
countAfterStmt.bindText(1, conversationId)
countAfterStmt.bindLong(2, after.toEpochMilliseconds())
countAfterStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
countAllStmt.close()
countAfterStmt.close()
if (ownsConnection) {
connection.close()
}
}
companion object {
/**
* Открывает in-memory соединение через [SQLiteConnection.memory] и
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
* runtime.
*/
fun memory(name: String? = null) =
KsqliteJournalStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.ksqlite
package pw.binom.agentik.journal.ksqlite
import kotlin.time.Clock
import kotlin.time.Instant
@@ -14,13 +14,50 @@ import kotlinx.coroutines.withContext
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
* в [Schema] (миграция через PRAGMA user_version) — этот класс только
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
*
* Каскадное удаление связанных данных (message + working_memory) делает
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
* store знает только про свою таблицу.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
* store НЕ закрывает его в [close] (используется shared-connection
* bundle'ом `SqliteStores.assemble`).
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
* в [close].
*/
class KsqliteMutableConversationStore(
class KsqliteMutableConversationStore private constructor(
private val connection: SQLiteConnection,
private val messageStore: KsqliteMessageStore? = null,
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
private val ownsConnection: Boolean,
) : MutableConversationStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`).
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
@@ -125,8 +162,6 @@ class KsqliteMutableConversationStore(
// Проверяем существование через raw query, НЕ через get() — get() тоже
// берёт mutex (не реентрант), что привело бы к deadlock.
if (!execExists(id)) return@withContext false
messageStore?.clear(id)
workingMemoryStore?.clear(id)
deleteStmt.reset()
deleteStmt.clearBindings()
deleteStmt.bindText(1, id)
@@ -188,6 +223,15 @@ class KsqliteMutableConversationStore(
renameStmt.close()
renameUpdatedAtStmt.close()
touchStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteMutableConversationStore =
KsqliteMutableConversationStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
private fun execExists(id: String): Boolean {
@@ -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(
@@ -5,32 +5,51 @@ import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
*
* Минимум — только то, что относится к `message` (append-only audit log).
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
* живут в других ksqlite-модулях.
* Владеет двумя таблицами:
* - `conversation` — реестр диалогов агента (см. ConversationRecord);
* - `message` — append-only audit log сообщений диалогов.
*
* `working_memory` и `reflection` живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
// ───── Таблицы ─────
const val TABLE_CONVERSATION = "conversation"
const val TABLE_MESSAGE = "message"
// ───── Колонки ─────
// ───── Колонки conversation ─────
const val COL_ID = "id"
const val COL_TITLE = "title"
const val COL_IS_TEMPORAL = "is_temporal"
const val COL_CREATED_AT = "created_at"
const val COL_UPDATED_AT = "updated_at"
// ───── Колонки message ─────
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ─────
const val IDX_CONV_UPDATED = "idx_conv_updated"
const val IDX_MSG_CONV = "idx_msg_conv"
private val v1Ddl = """
private val v1ConversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_TITLE TEXT,
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
"""
private val v1MessageDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
@@ -38,54 +57,43 @@ internal object Schema {
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
""".trimIndent()
"""
private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
-- сортировка по created_at (используется list(), cascade-clear, etc.)
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
""".trimIndent()
"""
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
* Гарантии:
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* уже-мигрированной БД;
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
* user_version как gate перестал работать — два модуля ставят его в 1,
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
*/
fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec(v1ConversationDdl)
conn.exec(v1MessageDdl)
conn.exec(v1IndexesDdl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
}
@@ -13,15 +13,9 @@ import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* Тесты для [KsqliteJournalStore] — точная копия
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory
* SQLiteConnection + Schema.migrate).
*
* Тест `testClearRemovesByConversation` из оригинала использовал
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
* автономен и не знает про ConversationStore.
* Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
* SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteJournalStoreTest {
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteJournalStore(conn)
}
@@ -103,4 +96,93 @@ class KsqliteJournalStoreTest {
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
}
@Test
fun testCountReturnsTotalForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
assertEquals(0L, store.count("conv1"))
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t, null))
store.append(MessageRecord.UserMessage("m3", "conv2", listOf(Content.Text("c")), t, null))
assertEquals(2L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("missing"))
}
@Test
fun testCountIsolatedFromOtherConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
// Bulk insert into conv1, single into conv2.
for (i in 1..50) {
store.append(MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t, null))
}
store.append(MessageRecord.UserMessage("n1", "conv2", listOf(Content.Text("only")), t, null))
assertEquals(50L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun testCountAfterFiltersStrictly() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
// Strictly > — t1 is not counted when after=t1.
assertEquals(2L, store.count("conv1", t1))
// Strictly > — t2 IS counted (only t3 after).
assertEquals(1L, store.count("conv1", t2))
// After last — empty.
assertEquals(0L, store.count("conv1", t3))
// Distant past — all three.
assertEquals(3L, store.count("conv1", Instant.DISTANT_PAST))
}
@Test
fun testCountAfterIsolatesByConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), t, null))
// Cursor that excludes everything — both conversations read 0.
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("conv1", future))
assertEquals(0L, store.count("conv2", future))
// Cursor that includes everything — only conv1's message matches its conversation.
assertEquals(1L, store.count("conv1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("conv2", Instant.DISTANT_PAST))
}
@Test
fun testCountAgreesWithListSize() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..10) {
store.append(
MessageRecord.UserMessage(
id = "m$i",
conversationId = "conv1",
content = listOf(Content.Text("x$i")),
createdAt = t0 + kotlin.time.Duration.parse("PT${i}S"),
context = null,
),
)
}
// count === list(..., 0, +∞).size
assertEquals(
store.list("conv1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("conv1"),
)
// count(after=t5) === list(..., after=t5, 0, +∞).size
val cutoff = t0 + kotlin.time.Duration.parse("PT5S")
assertEquals(
store.list("conv1", cutoff, 0, 1000).size.toLong(),
store.count("conv1", cutoff),
)
}
}
@@ -0,0 +1,146 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Тесты для [KsqliteMutableConversationStore]. Автономная фикстура —
* `SQLiteConnection.memory(...)` + конструктор `KsqliteMutableConversationStore(connection)`.
* Store сам прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteMutableConversationStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteMutableConversationStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("conv-${kotlin.random.Random.nextLong()}")
store = KsqliteMutableConversationStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun rec(id: String, title: String? = null, ts: Instant = Instant.parse("2026-09-15T10:00:00Z")) =
ConversationRecord(id, title, false, ts, ts)
@Test
fun testUpsertAndGetRoundtrip() = runTest {
store.upsert(rec("c1", "test"))
assertEquals(rec("c1", "test"), store.get("c1"))
}
@Test
fun testGetReturnsNullForMissing() = runTest {
assertNull(store.get("nope"))
}
@Test
fun testDeleteRemovesAndReturnsTrue() = runTest {
store.upsert(rec("c1"))
assertTrue(store.delete("c1"))
assertNull(store.get("c1"))
assertEquals(false, store.delete("c1"))
}
@Test
fun testListSortsByUpdatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(rec("c1", ts = t0))
store.upsert(rec("c2", ts = t0))
store.upsert(rec("c3", ts = t0))
store.upsert(rec("c4", ts = t0))
store.touch("c2", t0 + Duration.parse("PT60S"))
store.touch("c3", t0 + Duration.parse("PT120S"))
store.touch("c4", t0 + Duration.parse("PT180S"))
val page = store.list(offset = 0, limit = 4)
assertEquals(listOf("c4", "c3", "c2", "c1"), page.map { it.id })
}
@Test
fun testListRespectsOffsetAndLimit() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..5) store.upsert(rec("c$i", ts = t0 + Duration.parse("PT${i}S")))
val p0 = store.list(offset = 0, limit = 2)
assertEquals(2, p0.size)
val p2 = store.list(offset = 4, limit = 2)
assertEquals(1, p2.size)
}
@Test
fun testRenameUpdatesTitleAndUpdatedAt() = runTest {
store.upsert(rec("c1"))
val newTs = store.rename("c1", "new title")
assertNotNull(newTs)
assertEquals("new title", store.get("c1")?.title)
}
@Test
fun testRenameWithNullClearsTitle() = runTest {
store.upsert(rec("c1", "old"))
store.rename("c1", null)
assertNull(store.get("c1")?.title)
}
@Test
fun testRenameReturnsNullForMissing() = runTest {
assertNull(store.rename("nope", "x"))
}
@Test
fun testTouchUpdatesUpdatedAtOnly() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
store.touch("c1", t1)
val got = store.get("c1")
assertEquals("title", got?.title)
assertEquals(t1, got?.updatedAt)
assertEquals(t0, got?.createdAt)
}
@Test
fun testMemoryFactoryAutoMigratesSchema() = runTest {
// Smoke-test: .memory() companion-фабрика должна прогнать Schema.migrate()
// автоматически. Если бы миграция не сработала — storePreparedStatement'ы
// упали бы на `prepare failed: no such table: conversation` ещё в конструкторе.
val owned = KsqliteMutableConversationStore.memory("conv-auto-${kotlin.random.Random.nextLong()}")
try {
owned.upsert(rec("c1", "hello"))
assertEquals("hello", owned.get("c1")?.title)
} finally {
owned.close()
}
}
@Test
fun testExternalConnectionConstructorAlsoMigrates() = runTest {
// Внешний конструктор `(connection)` ТОЖЕ мигрирует (Schema.migrate idempotent).
// Caller может не звать Schema.migrate перед конструктором.
val externalConn = SQLiteConnection.memory("conv-external-${kotlin.random.Random.nextLong()}")
val s = KsqliteMutableConversationStore(externalConn)
try {
s.upsert(rec("c1"))
assertNotNull(s.get("c1"))
} finally {
s.close()
externalConn.close()
}
}
}
@@ -1,26 +1,31 @@
package pw.binom.agentik.storage.ksqlite
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest
import 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():
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION;
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на
* повторных CREATE);
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores),
* migrate() приводит её в боевое состояние.
* Тесты на 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.
*
* Также проверяем что наличие индекса idx_msg_conv (conversation_id +
* created_at) — обязательный hot-path для list()/cascade-delete.
* `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
* в `pw.binom.agentik.reflection.ksqlite`.
*/
class SchemaMigrationTest {
@Test
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest {
fun `fresh DB gets conversation and message tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
@@ -28,8 +33,6 @@ class SchemaMigrationTest {
for (table in listOf(
Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE,
Schema.TABLE_WORKING_MEMORY,
Schema.TABLE_REFLECTION,
)) {
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
}
@@ -37,15 +40,9 @@ class SchemaMigrationTest {
for (index in listOf(
Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_CONV,
Schema.IDX_WM_UNIQUE,
Schema.IDX_WM_CONV,
Schema.IDX_REFLECTION_CREATED,
Schema.IDX_REFLECTION_CONV,
)) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
}
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
} finally {
conn.close()
}
@@ -56,66 +53,50 @@ class SchemaMigrationTest {
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
val versionAfterFirst = readUserVersion(conn)
// повторный вызов не должен ни упасть, ни изменить версию, ни
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
// повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn)
assertEquals(versionAfterFirst, readUserVersion(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 bundle`() = runTest {
// Имитируем сценарий: существующая БД без schema, открываем через
// ksqlite и прогоняем migrate руками (тот же путь, что в
// KsqliteStores.open, но без зависимости от фабрики).
fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
// Сборка bundle через internal-конструктор — KsqliteStores primary
// constructor internal, тест в том же модуле и может его звать.
val stores = KsqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteMessageStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
val convStore = KsqliteMutableConversationStore(conn)
val msgStore = KsqliteJournalStore(conn)
try {
// bundle работает end-to-end — conversation upsert + message append +
// list. Никаких "no such table" или подобного.
stores.conversations.upsert(
pw.binom.agentik.journal.ConversationRecord(
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"),
)
)
stores.messages.append(
pw.binom.agentik.journal.MessageRecord.UserMessage(
msgStore.append(
MessageRecord.UserMessage(
id = "m1", conversationId = "c1",
content = listOf(pw.binom.agentik.journal.Content.Text("hi")),
content = listOf(Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
)
)
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size)
assertEquals("m1", got[0].id)
} finally {
// Закрываем store'ы → они закроют свои pre-prepared statements
// (StmtHolder.finalize увидит isOpen == false и не полезет в
// нативный sqlite3_finalize с уже-разрушенным db mutex).
stores.close()
convStore.close()
msgStore.close()
}
}
@Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
// Проверяем что индекс действительно покрывает обе колонки — без
// этого list()/cascade-delete будут делать full-scan по message.
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
@@ -158,16 +139,4 @@ class SchemaMigrationTest {
}
return cols
}
private fun readUserVersion(conn: SQLiteConnection): Int {
var version = 0
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) {
version = (rs.getLong(0) ?: 0L).toInt()
}
}
}
return version
}
}
+16 -7
View File
@@ -2,9 +2,8 @@
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :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()
@@ -106,4 +106,60 @@ sealed interface Event {
@Serializable
@SerialName("error")
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
/**
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
* kernel прибил таск). Отличается от [ToolResult]: там мы сообщаем LLM
* результат (даже если LLM его не понравился), тут — сигнал о
* внутренней ошибке **самого исполнения тула**. Используется
* background-подписчиками (например [ReflectionScheduler]-like
* компонентами) для накопления паттернов отказов. Клиенту
* показывается для transparency, но в UI особо не нужен.
*/
@Serializable
@SerialName("tool_failed")
data class ToolFailed(
override val date: Instant,
val toolCallId: String,
val toolName: String?,
val message: String,
val durationMs: Long,
) : Event
/**
* Диалог переходит в закрытое состояние ([Conversation.close] /
* [ConversationLoop.close] / `agent.deleteConversation`). Эмитится
* **до** освобождения ресурсов, чтобы background-подписчики
* (skill mining, reflection) успели сделать final pass. После
* `Closed` диалог уже удалён из `agent.getConversations()` и
* `getMessages()` отдаст только то, что осталось в журнале.
*
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
* так как открытие тривиально (id уже известен с момента
* `Agent.createConversation` → [Event.ConversationCreated]
* / [AgentEvent.Created] в outbox'е).
*/
@Serializable
@SerialName("conversation_closing")
data class ConversationClosing(
override val date: Instant,
val conversationId: String,
) : Event
/**
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
* Эмитится **до** deletion для background-подписчиков (skill mining),
* чтобы они успели сделать pass на исчезающем контенте.
*
* В отличие от [ToolFailed]/[ConversationClosing], это событие
* семантически "много контента ушло" — subscribers могут решать,
* стоит ли тратить tokens на mining ([turnsCompacted] > N).
*/
@Serializable
@SerialName("compaction_triggered")
data class CompactionTriggered(
override val date: Instant,
val conversationId: String,
val turnsCompacted: Int,
) : Event
}
@@ -24,8 +24,9 @@ data class Reflection(
)
/**
* Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу (impl в
* `:storage-sqlite`) или in-memory (impl в `:storage-inmemory`).
* Хранилище рефлексий. Реализации:
* - `:reflection-ksqlite` — таблица `reflection` поверх ksqlite
* - `:reflection-inmemory` — `Map<String, Reflection>` для тестов / dev
*
* Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup)
* или архивируются через [Curator]-подобный процесс, но не редактируются.
@@ -33,8 +34,10 @@ data class Reflection(
interface ReflectionStore : AutoCloseable {
suspend fun insert(reflection: Reflection)
suspend fun get(id: String): Reflection?
/** Самые свежие рефлексии (по всему агенту). */
suspend fun listRecent(limit: Int = 10): List<Reflection>
/** Рефлексии для конкретного диалога. */
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
suspend fun deleteOlderThan(cutoff: Instant)
@@ -55,5 +58,5 @@ sealed interface ReflectionEvent {
* БД-схемах было видно сразу.
*/
object Ids {
fun new(): String = "refl-${kotlin.uuid.Uuid.random()}"
fun new() = "refl-${kotlin.uuid.Uuid.random()}"
}
+36
View File
@@ -0,0 +1,36 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [ReflectionStore] поверх in-memory структур
// (`MutableMap`/`MutableList` + `Mutex`) — для тестов, dev-режима,
// embedded-сценариев (Android core, CLI, in-process кэш в клиенте)
// и как образец для своей реализации.
//
// Зависимости: только `:reflection-api` (+ `:journal-api` транзитивно
// через :reflection-api — ReflectionEvent.Created/и др. ссылаются на
// MessageRecord enum'ы). Никакого I/O — pure in-memory.
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":reflection-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.inmemory
package pw.binom.agentik.reflection.inmemory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.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
@@ -16,11 +16,42 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class KsqliteReflectionStore(
/**
* ksqlite-реализация [ReflectionStore].
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteReflectionStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle).
* - `KsqliteReflectionStore(path)` — открывает файловое соединение,
* прогоняет [Schema.migrate], закрывает соединение в [close].
* - `KsqliteReflectionStore.memory(name)` — in-memory, мигрирует,
* закрывает в [close].
*/
class KsqliteReflectionStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
private val clock: Clock = Clock.System,
) : ReflectionStore {
constructor(path: String, clock: Clock = Clock.System) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
clock = clock,
)
constructor(connection: SQLiteConnection, clock: Clock = Clock.System) : this(
connection = connection,
ownsConnection = false,
clock = clock,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16)
@@ -155,6 +186,16 @@ class KsqliteReflectionStore(
listForConvStmt.close()
deleteOlderThanStmt.close()
countStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null, clock: Clock = Clock.System): KsqliteReflectionStore =
KsqliteReflectionStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
clock = clock,
)
}
private fun SQLiteResultSet.toDomain(): Reflection = Reflection(
@@ -168,7 +209,7 @@ class KsqliteReflectionStore(
)
}
/** Hand-rolled JSON-encode/decode для List<String> — синхронизировать с :storage-sqlite. */
/** Hand-rolled JSON-encode/decode для List<String>. */
internal fun encodeStringArray(items: List<String>): String = buildString {
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() }
}
}
}
+3
View File
@@ -49,6 +49,9 @@ kotlin {
implementation(kotlin("test"))
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(project(":journal-inmemory"))
}
}
}
@@ -23,6 +23,10 @@ import pw.binom.agentik.journal.JournalStore
* project'нутые proto-[pw.binom.agentik.proto.Message]), здесь клиент
* получает полный transcript с tool-call/tool-result/error payload-ами,
* turn-tokens и context-метаданными.
* - `GET /conversations/{id}/count?after=` — сколько сообщений в диалоге
* всего (без `after`) или строго позже `after` (с `after`). Лёгкий
* endpoint для UI-бейджей "N новых сообщений" и compaction-метрик;
* тело ответа — JSON `{"count": <Long>}`.
*
* **Read-only:** [JournalStore] не имеет `append` — запись только через
* writer-референс, который ChatAgent держит внутри (тип `MutableJournalStore`,
@@ -40,5 +44,19 @@ fun Route.journalRoutes(
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
call.respond(journal.list(id, after, offset, limit))
}
get("/conversations/{id}/count") {
val id = call.parameters["id"]!!
val after = call.parseAfter()
val count = if (after == null) {
journal.count(id)
} else {
journal.count(id, after)
}
call.respond(CountResponse(count = count))
}
}
}
/** Тело ответа `GET /conversations/{id}/count`. */
@kotlinx.serialization.Serializable
private data class CountResponse(val count: Long)
@@ -44,6 +44,8 @@ class BearerTokenTest {
) : Agent {
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L
override suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {}
}
+37 -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,25 +101,42 @@ 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+.
include(":agent-toolsets")
// API-модуль vector-index: интерфейсы ANN-индекса, общие для любых бэкендов
// (JVector, sqlite-vec, inmemory, ...). Pure KMP, без реализаций.
include(":vector-index-api")
// JVector-реализация `MutableVectorIndexStore`. JVM-only — JVector публикуется
// только под JVM (нет KMP-таргетов). In-RAM (без persistence в v1).
include(":vector-index-jvector")
// ksqlite-реализация `MutableVectorIndexStore`. KMP (все 9 целей) — brute-force
// ANN: SELECT всех записей + cosine similarity в Kotlin. Persistence — обычная
// SQLite БД. Подходит для small-to-medium масштабов; для больших — sqlite-vec
// или JVector (выше).
include(":vector-index-ksqlite")
// Расширяемый слой поверх :proto: `MutableAgent` (наследник Agent + install/uninstall
// компонентов), `Component` (install(MutableAgent)/uninstall(MutableAgent)) и три
// capability-провайдера (system-prompt, tools, event-listener). Каждый компонент
// сам регистрирует свои capability в MutableAgent при install и снимает их при
// uninstall. KMP, все 9 целей; чистый API-слой, без реализаций.
include(":agent-api")
// Skill-подсистема как компонент: тулы skill_save/skill_delete/skill_read +
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
include(":skill-mining")
+44
View File
@@ -0,0 +1,44 @@
// Skill-подсистема как Component: тулы skill_save/skill_delete/skill_read +
// SkillMiningComponent, слушающий SkillMiningEvent и добывающий новые скилы
// через SkillMiner.
//
// KMP (9 целей — как :agent-toolsets): SkillMiningComponent в commonMain,
// SkillMiner из :llm-tools тоже KMP — нет блокеров для натива.
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :agent-api — Component / MutableAgent / ConversationAware.
api(project(":agent-api"))
// :skills — SkillStore, SkillCatalog, renderSystemPromptSection.
api(project(":skills"))
// :memory-api — typealias ConversationTurn.
api(project(":memory-api"))
// :litert-api — LiteTool / LiteLlm.
api(libs.litert.api)
// Корутины для фонового слушателя событий.
api(libs.kotlinx.coroutines.core)
// Логгер для SkillMiner.
implementation(libs.kotlin.logging)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.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,155 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.ConversationAware
import pw.binom.agentik.agent.ConversationHandle
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.skills.renderSystemPromptSection
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteTool
/**
* События, по которым SkillMiningComponent решает, что пора майнить новые скилы.
*
* Standalone-часть мэпит свой [pw.binom.agentik.outbox.OutboxStore] (через
* [pw.binom.agentik.outbox.Event.ConversationClosing] и
* [pw.binom.agentik.outbox.Event.CompactionTriggered]) на этот sealed
* interface и подаёт результат в [SkillMiningComponent.events]. Делаем так,
* чтобы модуль :skill-mining не зависел от :standalone и его внутренних типов.
*/
sealed interface SkillMiningEvent {
val conversationId: String
/** Разговор закрывается — последний шанс вытащить скилы из контекста. */
data class ConversationClosing(override val conversationId: String) : SkillMiningEvent
/** Сработал compactor — старые turn'ы ушли, mining нужен на новом контексте. */
data class ConversationCompacted(override val conversationId: String) : SkillMiningEvent
}
/**
* Полная подсистема скилов как один Component:
* 1. Тулы skill_save / skill_delete / skill_read — через [SkillToolProvider].
* 2. Секция системного промпта с актуальным каталогом — через [SkillSystemProvider].
* 3. Фоновый майнер — на [SkillMiningEvent.ConversationClosing] /
* [SkillMiningEvent.ConversationCompacted] вызывает [SkillMiner] над
* последними turn'ами разговора и пишет новые скилы в [SkillStore].
*
* Поведение майнера зависит от per-conversation state ([ConversationHandle]):
* - isTemporal=true → пропускаем (mining не имеет смысла для temp-бесед);
* - recentTurns(limit) → что майнер видит.
*
* Подключается как обычный Component: `agent.install(SkillMiningComponent(...))`.
* Дополнительно компонент реализует [ConversationAware] — [MutableAgent]
* оповещает его о создании/закрытии каждого разговора.
*/
class SkillMiningComponent(
private val store: SkillStore,
private val llm: LiteLlm,
private val events: Flow<SkillMiningEvent>,
private val scope: CoroutineScope,
) : Component, ConversationAware {
private var toolProvider: SkillToolProvider? = null
private var systemProvider: SkillSystemProvider? = null
private var minerJob: Job? = null
/** Per-conversation context для mining: id → handle. */
private val handles = mutableMapOf<String, ConversationHandle>()
private val handlesLock = Mutex()
override fun install(agent: MutableAgent) {
val tp = SkillToolProvider(store)
agent.toolProviders.add(tp)
toolProvider = tp
val sp = SkillSystemProvider(store)
agent.systemProviders.add(sp)
systemProvider = sp
// Подписка на фоновые события — единая на компонент (не на разговор).
minerJob = scope.launch {
events.collect { event ->
when (event) {
is SkillMiningEvent.ConversationClosing,
is SkillMiningEvent.ConversationCompacted -> {
mineForConversation(event.conversationId)
}
}
}
}
}
override fun uninstall(agent: MutableAgent) {
minerJob?.cancel()
minerJob = null
toolProvider?.let { agent.toolProviders.remove(it) }
systemProvider?.let { agent.systemProviders.remove(it) }
// очистка под mutex: detach-вызовы могут быть в полёте из других корутин
runBlocking { handlesLock.withLock { handles.clear() } }
}
/** [ConversationAware]: новый разговор — запоминаем handle. */
override fun attachConversation(handle: ConversationHandle) {
// attach вызывается синхронно из MutableAgent.install-flow,
// целостность важна только между attach/detach и чтением из mining-корутины
runBlocking { handlesLock.withLock { handles[handle.id] = handle } }
}
/** [ConversationAware]: разговор закрыт — забываем handle. */
override fun detachConversation(handle: ConversationHandle) {
runBlocking { handlesLock.withLock { handles.remove(handle.id) } }
}
private fun mineForConversation(conversationId: String) {
// горячий путь: одна mining-корутина читает snapshot handle и идёт работать.
// блокировка короткая, copy-on-read не нужно — handle'ы immutable в нашей модели.
val handle = runBlocking { handlesLock.withLock { handles[conversationId] } } ?: return
if (handle.isTemporal) return
scope.launch {
val miner = SkillMiner(llm)
val turns = runCatching { handle.recentTurns(miner.maxTurns) }.getOrDefault(emptyList())
if (turns.isEmpty()) return@launch
runCatching {
// ConversationTurn (agent-api) == ConversationTurn (memory-api) через typealias.
miner.mine(turns, store.catalog.skills)
}
.onSuccess { mined ->
mined.forEach { runCatching { store.upsert(it) } }
}
}
}
}
private class SkillToolProvider(
private val store: SkillStore,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> {
val catalog: SkillCatalog = store.catalog
return buildList {
add(SkillSaveTool(store))
add(SkillDeleteTool(store))
if (!catalog.isEmpty) add(SkillReadTool(catalog))
}
}
}
private class SkillSystemProvider(
private val store: SkillStore,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String =
store.catalog.renderSystemPromptSection()
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools
package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillFile
@@ -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)
-2
View File
@@ -4,7 +4,6 @@
Главный исполняемый модуль проекта — single-jar HTTP-сервер с:
- **AG-UI** transport на `POST /agui` (SSE) + `GET /health`.
- **A2A** transport на `POST /` (JSON-RPC) + `GET /.well-known/agent-card.json`.
- **`:proto`** transport на `POST /agentik/*` (HTTP+JSON+SSE) — наш stateful.
- **Embedded LLM backend**: `GOOGLE` (LiteRT) или `OPENAI`-совместимый
@@ -98,7 +97,6 @@ AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
| Метод | Путь | Transport | Описание |
|---|---|---|---|
| `GET` | `/health` | любой | health-check (`{"ok":true}`) |
| `POST` | `/agui` | AG-UI | Стриминг run (SSE) |
| `POST` | `/` | A2A | JSON-RPC `message/send`, `tasks/get`, `tasks/cancel` |
| `GET` | `/.well-known/agent-card.json` | A2A | Discovery |
| `POST` | `/agentik/conversations` | :proto | Создать диалог |
+21 -6
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,25 +64,40 @@ 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)
api(libs.litert.openai)
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
runtimeOnly(libs.litert.google)
api(libs.litert.google)
// Ktor server (для :server facade + a2aServer)
// Используем CIO вместо Netty — он KMP (jvm + linuxX64/ios/...), нам нужен
@@ -183,9 +198,9 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
// выровнено по стилю существующего деплоя).
//
// SSH — ключевой, пароль не спрашивает. Если упадёт — проверь
// `ssh root@192.168.76.166 'echo OK'` вручную.
// `ssh root@192.168.76.156 'echo OK'` вручную.
val agentikDeployHost: String =
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.166"
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.156"
val agentikDeployPath: String =
(project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar"
@@ -13,7 +13,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.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)
@@ -84,7 +84,7 @@ fun main(args: Array<String>) {
private fun printHelp() {
println("""
agentik standalone — usage:
java -jar agentik.jar Start HTTP server (AGUI + A2A + :proto)
java -jar agentik.jar Start HTTP server (A2A + :proto)
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
""".trimIndent())
}
@@ -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,
@@ -381,8 +380,7 @@ private fun runServer() {
contextCompactor = contextCompactor,
recentReflections = recentReflections,
reflector = reflector,
skillMiner = skillMiner,
)
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
// чтобы debug-эндпоинты могли его триггерить вручную.
@@ -1,84 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Internal event bus для background work — separate from [ConversationEvents]
* (который это SSE-event stream для клиента).
*
* Background work fires на **structural events**, не на interval-polling:
* - **Review** — триггерится в CompactionCoordinator ПРЯМО ПЕРЕД удалением ходов
* из working memory (last chance вытащить факты). Это уже было сделано
* через `reviewer.reviewPreCompaction()` — оставляем как есть.
* - **Reflection** — на `ConversationLifecycleEvent.Closing` (финальная
* рефлексия перед закрытием) ИЛИ накопление N tool-failures в окне
* (что-то идёт не так).
* - **Skill mining** — на `CompactionEvent.Triggered` если `turnsToDelete > N`
* (есть контент для минера) ИЛИ на `ConversationLifecycleEvent.Closing`.
*
* Subscribers (BackgroundScheduler) решают, что делать. НЕ текстовая
* инспекция, НЕ regex — только структурные события с явным семантическим
* смыслом. См. STANDALONE-REVIEW раздел "event-driven background".
*/
/** Эмитится из [ToolDispatcher] после каждого `runToolAndPersist` (success/failure). */
sealed interface ToolCallEvent {
val toolName: String
data class Succeeded(
override val toolName: String,
val durationMs: Long,
) : ToolCallEvent
data class Failed(
override val toolName: String,
val error: String,
) : ToolCallEvent
}
/** Эмитится из [CompactionCoordinator] ПЕРЕД `workingMemory.compact(...)`. */
sealed interface CompactionEvent {
/**
* Compaction сейчас удалит N ходов из working memory. Background work
* имеет последний шанс вытащить оттуда данные.
*/
data class Triggered(
val turnsToDelete: Int,
val conversationId: String,
) : CompactionEvent
}
/** Эмитится из `ConversationLoop.close()` сразу ПЕРЕД `agentScope.cancel()`. */
sealed interface ConversationLifecycleEvent {
data class Closing(val conversationId: String) : ConversationLifecycleEvent
}
internal class BackgroundEventBus {
private val _toolCallEvents = MutableSharedFlow<ToolCallEvent>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val toolCallEvents: SharedFlow<ToolCallEvent> get() = _toolCallEvents.asSharedFlow()
private val _compactionEvents = MutableSharedFlow<CompactionEvent>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val compactionEvents: SharedFlow<CompactionEvent> get() = _compactionEvents.asSharedFlow()
private val _lifecycleEvents = MutableSharedFlow<ConversationLifecycleEvent>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val lifecycleEvents: SharedFlow<ConversationLifecycleEvent> get() = _lifecycleEvents.asSharedFlow()
fun tryEmit(event: ToolCallEvent): Boolean = _toolCallEvents.tryEmit(event)
fun tryEmit(event: CompactionEvent): Boolean = _compactionEvents.tryEmit(event)
fun tryEmit(event: ConversationLifecycleEvent): Boolean = _lifecycleEvents.tryEmit(event)
}
@@ -1,25 +1,41 @@
package pw.binom.agentik.standalone.agent
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.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.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.proto.Agent as ProtoAgent
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.Event as OutboxEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.MutableOutboxStore
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 +46,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 +64,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 +81,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 +89,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 +99,7 @@ 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 agentScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
private val memoryReviewer: MemoryReviewer? = null,
/**
* Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
@@ -105,112 +129,258 @@ class ChatAgent(
* Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен.
*/
private val reflector: LlmReflector? = null,
/**
* Фоновый минер скилов: LLM-вызов, который запускается на compaction
* (`turnsToDelete > 10`) или при closing conversation. `null` = mining выключен.
* Сетка безопасности, если модель забыла вызвать `skill_save` сама.
*/
private val skillMiner: SkillMiner? = null,
/**
* Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает
* о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются,
* секция в 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,
)
/**
* Тестовый инжектор: позволяет `ChatAgentTest` подсунуть тул, который
* скриптованная LLM будет вызывать. В production-конфигурации всегда пуст —
* инструменты приходят через [toolProviders] (компоненты, [install]).
*/
private val testTools: MutableList<LiteTool> = mutableListOf()
/**
* Единый канал всех событий агента — bounded tail с auto-TTL.
*
* Заменил ранее существовавшие два канала:
* - `agentEvents: MutableSharedFlow<AgentEvent>` (agent lifecycle)
* - per-conv `ConversationEvents._flow: MutableSharedFlow<ProtoEvent>`
*
* Теперь оба пишут сюда через [MutableOutboxStore.append], а consumer'ы
* читают через [EventStore.events]/[conversationEvents]/[agentEvents].
*
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
* или подписке на два отдельных канала.
*/
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
maxMessages = null,
ttl = null,
)
/**
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные:
* `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть
* [skillStore]), `memory_*` (если подключена память),
* `enable_toolset`/`disable_toolset` (если заданы [toolsets]).
* MCP-тулы префиксованы `server__`, так что коллизий нет.
* Собирает **актуальный** список тулов для диспетчеризации:
* внешние из [toolProviders] + testTools + встроенные (read_skill,
* skill_save, memory_*, toolset enable/disable).
* Вызывается **на каждый tool-call** через [toolsetDispatch] (поэтому без
* кэша — добавление компонента сразу видно в dispatch).
*
* Дедупликация не делается — если два провайдера вернут тул с одинаковым
* именем, второй в lookup'е победит первый. Это намеренно оставлено на
* ответственности компонентов (в типичной конфигурации каждый компонент
* отвечает за свой namespace-префикс, и коллизий нет).
*/
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))
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
/**
* Единый канал всех событий агента — bounded tail с auto-TTL.
* Подписка на skill-mining через outbox (единый канал всех событий):
* - [OutboxEvent.ConversationClosing] → [SkillMiningEvent.ConversationClosing]
* - [OutboxEvent.CompactionTriggered] → [SkillMiningEvent.ConversationCompacted]
*
* Заменил ранее существовавшие два канала:
* - `agentEvents: MutableSharedFlow<AgentEvent>` (agent lifecycle)
* - per-conv `ConversationEvents._flow: MutableSharedFlow<ProtoEvent>`
*
* Теперь оба пишут сюда через [MutableOutboxStore.append], а consumer'ы
* читают через [EventStore.events]/[conversationEvents]/[agentEvents].
*
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
* или подписке на два отдельных канала.
* Оба маппатся в один [Flow], `SkillMiningComponent` сам решает что делать.
*/
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
maxMessages = null,
ttl = null,
)
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
eventStore.events(after = null)
.filterIsInstance<CommonEvent.Conversation>()
.mapNotNull { ce ->
when (val e = ce.event) {
is OutboxEvent.ConversationClosing -> SkillMiningEvent.ConversationClosing(ce.conversationId)
is OutboxEvent.CompactionTriggered -> SkillMiningEvent.ConversationCompacted(ce.conversationId)
else -> null
}
}
internal fun buildRuntimeSystemPrompt(): String {
// Секции от systemProviders идут ПЕРЕД базой — это позволяет компонентам
// типа SoulComponent поставить себя во главу угла. Если нужна секция
// после базы — у компонента должен быть какой-то признак "prepend: false"
// (сейчас все systemProviders считаются prepend-only).
if (systemProviders.isEmpty()) return systemPrompt
val sections = systemProviders.mapNotNull { p ->
val section = p.getSection(conversationId = "")
section.takeIf { it.isNotBlank() }
}
if (sections.isEmpty()) return systemPrompt
return sections.joinToString("\n\n") + "\n\n" + systemPrompt.trimEnd()
}
/** Прокидывает [component] в [MutableAgent] и запоминает для [close]. */
override fun install(component: Component): MutableAgent {
component.install(this)
components.add(component)
return this
}
/** Снимает [component]. Идемпотентно — повторный uninstall безопасен. */
override fun uninstall(component: Component): MutableAgent {
component.uninstall(this)
components.remove(component)
return this
}
/**
* Оповещает [ConversationAware] компоненты о новом разговоре. Вызывается
* из [ChatConversation] сразу после конструктора — компонент может
* подписаться на events и запустить фоновые задачи.
*/
override fun attachConversation(handle: ConversationHandle) {
components.filterIsInstance<ConversationAware>().forEach { it.attachConversation(handle) }
}
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
override fun detachConversation(handle: ConversationHandle) {
components.filterIsInstance<ConversationAware>().forEach { it.detachConversation(handle) }
}
/**
* Проекция ChatConversation → ConversationHandle. Даём per-conversation
* компонентам только минимальный view (id / isTemporal / recentTurns),
* чтобы они не лезли в чужие обязанности.
*
* [recentTurns] берёт последние [limit] реплик из working memory —
* это всё, что нужно [pw.binom.agentik.skill.mining.SkillMiningComponent] /
* [LlmReflector] для добычи знаний.
*/
private fun ChatConversation.asHandle(): ConversationHandle = object : ConversationHandle {
override val id: String get() = this@asHandle.id
override val isTemporal: Boolean get() = this@asHandle.isTemporal
override suspend fun recentTurns(limit: Int): List<ConversationTurn> {
val rows = workingMemoryStore.list(this@asHandle.id)
val filtered = rows.map { it.entry }.filter {
it is pw.binom.agentik.context.WorkingMemoryEntry.User ||
it is pw.binom.agentik.context.WorkingMemoryEntry.Assistant
}
// Схлопываем User/Assistant в пары user+assistant.
// Если последним идёт User без пары — assistantMessage пустой.
val pairs = mutableListOf<ConversationTurn>()
var pendingUser: String? = null
for (entry in filtered.takeLast(limit * 2)) {
val text = when (entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
.joinToString("\n") { it.body }
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
.joinToString("\n") { it.body }
else -> continue
}
when (entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = text
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> {
pairs += ConversationTurn(userMessage = pendingUser.orEmpty(), assistantMessage = text)
pendingUser = null
}
}
}
pendingUser?.let { pairs += ConversationTurn(userMessage = it, assistantMessage = "") }
return pairs.takeLast(limit)
}
override fun close() = Unit
}
/**
* Read-only view of [messageStore] для HTTP-фасада в `:server`
@@ -278,7 +448,9 @@ class ChatAgent(
eventStore = eventStore,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
@@ -287,12 +459,14 @@ class ChatAgent(
compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor,
reflector = reflector,
skillMiner = skillMiner,
skillMiningStore = skillStore,
)
runBlocking {
liveLock.withLock { live[conv.id] = conv }
}
// Оповещаем ConversationAware компоненты (например, SkillMiningComponent)
// о новом разговоре — они могут подписаться на events и запустить
// фоновые задачи.
attachConversation(conv.asHandle())
runBlocking {
eventStore.append(
CommonEvent.Agent(
@@ -309,14 +483,21 @@ 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) {
// Каскад — здесь, потому что владелец lifecycle диалога
// (ChatAgent) имеет доступ ко всем трём store'ам, а каждый
// отдельный store должен знать только про свою таблицу.
messageStore.clear(id)
workingMemoryStore.clear(id)
val event = AgentEvent.Deleted(date = now(), id = id)
eventStore.append(CommonEvent.Agent(date = now(), event = event))
}
@@ -339,26 +520,34 @@ class ChatAgent(
eventStore = eventStore,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
memoryStoreForReview = memoryStore,
contextWindow = contextWindow,
compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor,
reflector = reflector,
skillMiner = skillMiner,
skillMiningStore = skillStore,
)
contextCompactor = contextCompactor,
reflector = reflector,
)
override fun close() {
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() }
}
@@ -375,28 +564,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
}
/**
@@ -3,7 +3,7 @@ package pw.binom.agentik.standalone.agent
/**
* Историческое имя класса. До 2025-Q4 разбиения god-class а на компоненты
* (`ConversationState`, `ConversationEvents`, `ContextBuilder`,
* `CompactionCoordinator`, `ToolDispatcher`, `BackgroundScheduler`) вся
* `CompactionCoordinator`, `ToolDispatcher`, `ReflectionScheduler`) вся
* логика жила в `class ChatConversation` здесь, ~1400 строк.
*
* После рефактора — реализация переехала в [ConversationLoop] (этот же пакет).
@@ -18,6 +18,7 @@ import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.SummaryTurn
import kotlin.time.Instant
internal class CompactionCoordinator(
private val state: ConversationState,
@@ -28,13 +29,14 @@ internal class CompactionCoordinator(
private val memoryStoreForReview: MemoryStore?,
private val workingMemory: ContextStore,
private val liteLlm: LiteLlm,
private val events: ConversationEvents,
private val now: () -> Instant,
private val systemPrompt: String,
private val backgroundEvents: BackgroundEventBus,
) {
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)
@@ -66,11 +68,7 @@ internal class CompactionCoordinator(
}
if (toCompact.isEmpty()) return false
// Emit CompactionEvent BEFORE deletion. BackgroundScheduler может trigger'ить
// skill mining на основе turnsToDelete (есть контент — есть что майнить).
backgroundEvents.tryEmit(
CompactionEvent.Triggered(turnsToDelete = toCompact.size, conversationId = state.id),
)
events.tryEmit(pw.binom.agentik.outbox.Event.CompactionTriggered(date = now(), conversationId = state.id, turnsCompacted = toCompact.size))
val turns = toCompact.mapNotNull { row ->
when (val e = row.entry) {
@@ -147,7 +145,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 +194,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 +226,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,24 +244,13 @@ 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 {
private const val KEEP_RECENT_TURNS = 4
private const val MAX_SEEDED_MESSAGES = 50
/** Минимум удаляемых ходов чтобы BackgroundScheduler trigger'ил skill mining. */
/** Минимум удаляемых ходов чтобы SkillMiningComponent trigger'ил skill mining. */
private const val MIN_COMPACTION_FOR_MINING = 10
}
}
@@ -12,9 +12,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.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
@@ -27,7 +25,6 @@ import pw.binom.agentik.outbox.Event as ProtoEvent
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Message as ProtoMessage
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore
@@ -47,9 +44,7 @@ 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.llm.tools.ContextCompactor
import pw.binom.agentik.toolsets.NamedTool
class ConversationLoop(
record: ConversationRecord,
@@ -59,8 +54,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,
@@ -69,8 +86,6 @@ class ConversationLoop(
private val compressionThreshold: Double = 0.8,
private val contextCompactor: ContextCompactor? = null,
private val reflector: LlmReflector? = null,
private val skillMiner: SkillMiner? = null,
private val skillMiningStore: SkillStore? = null,
) : ProtoConversation, AutoCloseable {
private val log = KotlinLogging.logger {}
@@ -90,10 +105,7 @@ class ConversationLoop(
conversationId = state.id,
)
/** 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)
@@ -107,33 +119,29 @@ class ConversationLoop(
workingMemory = workingMemoryStore,
liteLlm = llm,
systemPrompt = systemPrompt,
backgroundEvents = backgroundEvents,
events = events,
now = ::now,
)
private val toolDispatcher = ToolDispatcher(
state = state,
messageStore = messageStore,
events = events,
backgroundEvents = backgroundEvents,
toolsByName = toolsByName,
toolsetDispatch = toolsetDispatch,
newId = ::newId,
encodeArgsJson = ::encodeArgsJson,
now = ::now,
)
private val backgroundScheduler = BackgroundScheduler(
private val reflectionScheduler = ReflectionScheduler(
state = state,
workingMemory = workingMemoryStore,
config = BackgroundConfig(
memoryReviewer = memoryReviewer,
memoryStore = memoryStoreForReview,
reflectionStore = reflectionStore,
config = ReflectionConfig(
reflector = reflector,
skillMiner = skillMiner,
skillMiningStore = skillMiningStore,
reflectionStore = reflectionStore,
),
backgroundEvents = backgroundEvents,
eventStore = eventStore,
conversationIdProvider = { id },
).also { it.start(agentScope) }
override val id: String get() = state.id
@@ -226,10 +234,12 @@ class ConversationLoop(
override fun close() {
if (state.isClosed) return
state.markClosed()
// Emit Closing event BEFORE agentScope.cancel() — BackgroundScheduler's подписка
// ловит это и делает final reflection + skill mining (last chance вытащить insights).
// Emit Closing event BEFORE agentScope.cancel() — ReflectionScheduler's подписка
// ловит это и делает final reflection (last chance вытащить insights).
// После cancel() подписка умерла бы.
backgroundEvents.tryEmit(ConversationLifecycleEvent.Closing(conversationId = id))
runCatching {
events.tryEmit(pw.binom.agentik.outbox.Event.ConversationClosing(date = now(), conversationId = id))
}
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
agentScope.cancel()
@@ -237,10 +247,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 +281,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 +313,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 +369,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)
@@ -452,9 +466,9 @@ class ConversationLoop(
)
}
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus
// (compaction/lifecycle/tool-failure events). Никаких interval-based
// вызовов сюда больше не идёт. См. BackgroundScheduler.kt.
// ReflectionScheduler is event-driven — подписан на outbox
// (Event.ConversationClosing / Event.ToolFailed). Никаких
// interval-based вызовов сюда больше не идёт. См. ReflectionScheduler.kt.
}
}
@@ -492,28 +506,6 @@ class ConversationLoop(
private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix)
private fun 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
@@ -2,57 +2,57 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.journal.Content
import pw.binom.agentik.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.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OutboxStore
import java.util.concurrent.atomic.AtomicLong
/**
* Background work подписчик на [BackgroundEventBus]. Заменяет старую interval-based
* логику (`maybeScheduleReview/Reflection/SkillMining` с `userTurnCount % N == 0`).
* Event-driven reflection trigger. Подписан на [OutboxStore] и пишет
* в [ReflectionStore] когда:
*
* Подписки:
* - [ConversationLifecycleEvent.Closing] → финальный reflection + skill mining
* перед закрытием conversation (last chance вытащить insights).
* - [CompactionEvent.Triggered] → skill mining если `turnsToDelete > MIN_COMPACTION_FOR_MINING`.
* Review уже сделан внутри CompactionCoordinator (`reviewPreCompaction`) — не дублируем.
* - [ToolCallEvent.Failed] (накопительно) → reflection если 2+ фейлов в окне 60 сек
* (что-то пошло не так — самоанализ полезен).
* - `Event.ConversationClosing` для нашего `conversationId` — финальная
* рефлексия перед закрытием. Skill mining на Closing уже делает
* [pw.binom.agentik.skill.mining.SkillMiningComponent].
* - `Event.ToolFailed` для нашего `conversationId` — накопительно:
* 2+ фейлов за 60 сек запускают рефлексию (что-то идёт не так —
* самоанализ полезен).
*
* НЕ текстовая инспекция, НЕ regex, НЕ interval-polling. Только структурные
* события с явным семантическим смыслом.
*
* Подписка идёт через outbox (а не через per-conversation BackgroundEventBus
* который был раньше): outbox — единый канал для всех событий (как клиентских,
* так и внутренних), persistent tail с TTL работает из коробки, а клиенты
* по тому же потоку могут самостоятельно видеть/логировать [Event.ToolFailed]
* без скрытой телеметрии.
*/
internal data class BackgroundConfig(
val memoryReviewer: MemoryReviewer?,
val memoryStore: MemoryStore?,
val reflectionStore: ReflectionStore?,
internal data class ReflectionConfig(
val reflector: LlmReflector?,
val skillMiner: SkillMiner?,
val skillMiningStore: SkillStore?,
val reflectionStore: ReflectionStore?,
)
internal class BackgroundScheduler(
internal class ReflectionScheduler(
private val state: ConversationState,
private val workingMemory: ContextStore,
private val config: BackgroundConfig,
private val backgroundEvents: BackgroundEventBus,
private val config: ReflectionConfig,
private val eventStore: OutboxStore,
private val conversationIdProvider: () -> String,
) {
private val log = KotlinLogging.logger {}
private val lastSkillMiningAt = AtomicLong(0)
private val lastReflectionAt = AtomicLong(0)
/** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */
@@ -65,17 +65,14 @@ internal class BackgroundScheduler(
fun start(scope: CoroutineScope) {
if (subscriptionJob?.isActive == true) return
subscriptionJob = scope.launch {
// Merge all three event flows into one subscription scope. Each onEach
// returns Unit, so launchIn merges them as cold flows.
merge(
backgroundEvents.lifecycleEvents
.filterIsInstance<ConversationLifecycleEvent.Closing>()
eventStore.events(after = null)
.filterIsInstance<CommonEvent.Conversation>()
.filter { it.event is Event.ConversationClosing && it.conversationId == conversationIdProvider() }
.onEach { onClosing() },
backgroundEvents.compactionEvents
.filterIsInstance<CompactionEvent.Triggered>()
.onEach { onCompaction(it) },
backgroundEvents.toolCallEvents
.filterIsInstance<ToolCallEvent.Failed>()
eventStore.events(after = null)
.filterIsInstance<CommonEvent.Conversation>()
.filter { it.event is Event.ToolFailed && it.conversationId == conversationIdProvider() }
.onEach { onToolFailure() },
).collect {}
}
@@ -83,9 +80,7 @@ internal class BackgroundScheduler(
private suspend fun onClosing() {
if (state.isTemporal) return
val convId = state.id
runFinalReflection(convId)
runFinalSkillMining(convId)
runFinalReflection(state.id)
}
private fun runFinalReflection(convId: String) {
@@ -105,51 +100,6 @@ internal class BackgroundScheduler(
}
}
private fun runFinalSkillMining(convId: String) {
val miner = config.skillMiner ?: return
val store = config.skillMiningStore ?: return
state.agentScope.launch {
try {
val turns = recentTurns(miner.maxTurns)
if (turns.isEmpty()) return@launch
val mined = miner.mine(turns, store.catalog.skills)
for (s in mined) {
runCatching { store.upsert(s) }
.onFailure { log.warn(it) { "final skill mining upsert '${s.name}' failed: ${it.message}" } }
}
log.info { "final skill mining on close: conv=$convId turns=${turns.size} existing=${store.catalog.skills.size} mined=${mined.size}" }
} catch (e: Throwable) {
log.warn(e) { "final skill mining failed for $convId: ${e.message}" }
}
}
}
private fun onCompaction(event: CompactionEvent.Triggered) {
if (state.isTemporal) return
if (event.turnsToDelete < MIN_COMPACTION_FOR_MINING) return
val miner = config.skillMiner ?: return
val store = config.skillMiningStore ?: return
val now = System.currentTimeMillis()
// Debounce: не чаще раза в минуту
if (now - lastSkillMiningAt.get() < MINING_DEBOUNCE_MS) return
lastSkillMiningAt.set(now)
val convId = event.conversationId
state.agentScope.launch {
try {
val turns = recentTurns(miner.maxTurns)
if (turns.isEmpty()) return@launch
val mined = miner.mine(turns, store.catalog.skills)
for (s in mined) {
runCatching { store.upsert(s) }
.onFailure { log.warn(it) { "compaction skill mining upsert '${s.name}' failed: ${it.message}" } }
}
log.info { "compaction skill mining: conv=$convId turnsToDelete=${event.turnsToDelete} mined=${mined.size}" }
} catch (e: Throwable) {
log.warn(e) { "compaction skill mining failed for $convId: ${e.message}" }
}
}
}
private fun onToolFailure() {
val reflector = config.reflector ?: return
if (state.isTemporal) return
@@ -158,21 +108,18 @@ internal class BackgroundScheduler(
val now = System.currentTimeMillis()
val shouldReflect = synchronized(toolFailuresLock) {
toolFailures.add(now)
// Trim old failures outside the window
val cutoff = now - FAILURE_WINDOW_MS
toolFailures.removeAll { it < cutoff }
toolFailures.size >= FAILURE_THRESHOLD
}
if (!shouldReflect) return
// Debounce reflection globally (не чаще раза в 5 мин)
if (now - lastReflectionAt.get() < REFLECTION_DEBOUNCE_MS) {
log.debug { "reflection debounced: ${toolFailures.size} failures accumulated but reflection fired recently" }
return
}
lastReflectionAt.set(now)
// Clear failure window — fresh accounting period
synchronized(toolFailuresLock) { toolFailures.clear() }
val convId = state.id
@@ -212,10 +159,6 @@ internal class BackgroundScheduler(
filterIsInstance<Content.Text>().joinToString("\n") { it.body }
companion object {
/** Минимум ходов, удаляемых compaction'ом, чтобы trigger'ить skill mining. */
private const val MIN_COMPACTION_FOR_MINING = 10
/** Дебаунс skill mining между запусками. */
private const val MINING_DEBOUNCE_MS = 60_000L
/** Debounce reflection между запусками (накопительный, не per-failure). */
private const val REFLECTION_DEBOUNCE_MS = 300_000L
/** Сколько tool-failures в окне должно накопиться чтобы trigger reflection. */
@@ -1,22 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.toolsets.NamedTool
/**
* Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style).
*
* Возвращает два NamedTool'а:
* - `skill_save(name, description, body)` — создать или обновить скил;
* - `skill_delete(name)` — архивировать скил.
*
* Оба оборачивают [SkillStore]; агент может дёргать их в любой момент,
* не только во время review-loop.
*/
object SkillToolsFactory {
fun create(store: SkillStore): List<NamedTool> = listOf(
NamedTool("skill_save", SkillSaveTool(store)),
NamedTool("skill_delete", SkillDeleteTool(store)),
)
}
@@ -12,17 +12,14 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.litert.LiteToolCall
import pw.binom.litert.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 +29,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 +59,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
@@ -95,13 +89,20 @@ internal class ToolDispatcher(
val resultAt = now()
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
// Эмитим background event — другие компоненты (BackgroundScheduler)
// решают, делать ли что-то. Cancellation = not a failure (не эмитим Failed).
// Только реальные падения тула попадают в outbox как background-event
// (reflection-подобные потребители). Cancellation — not a failure,
// успех — тоже без фонового события (ToolResult уже всё сказал).
val durationMs = System.currentTimeMillis() - startMs
if (failureError != null) {
backgroundEvents.tryEmit(ToolCallEvent.Failed(toolName = call.name, error = failureError))
} else if (resultText != "[cancelled by user]") {
backgroundEvents.tryEmit(ToolCallEvent.Succeeded(toolName = call.name, durationMs = durationMs))
events.tryEmit(
ProtoEvent.ToolFailed(
date = resultAt,
toolCallId = callId,
toolName = call.name,
message = failureError,
durationMs = durationMs,
),
)
}
if (!state.isTemporal) {
@@ -0,0 +1,52 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.litert.LiteTool
/**
* Подключает память агенту:
* - Тулы memory_save / memory_read / memory_list / memory_delete —
* через [MemoryToolProvider].
* - Секция системного промпта с категориями и форматом записи —
* через [MemorySystemProvider].
*
* Память опциональна — если [MemoryStore] == null, компонент не регистрирует
* ни провайдеры, ни system-секцию. Поведение идентично прошлому (когда
* `memoryStore=null` ничего не добавлялось).
*/
class MemoryComponent(
private val store: MemoryStore?,
) : Component {
private var toolProvider: MemoryToolProvider? = null
override fun install(agent: MutableAgent) {
if (store == null) return
val tp = MemoryToolProvider(store)
agent.toolProviders.add(tp)
toolProvider = tp
agent.systemProviders.add(MemorySystemProvider())
}
override fun uninstall(agent: MutableAgent) {
toolProvider?.let { agent.toolProviders.remove(it) }
agent.systemProviders.removeAll { it is MemorySystemProvider }
}
}
private class MemoryToolProvider(
private val store: MemoryStore,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> =
MemoryToolsFactory.create(store)
}
private class MemorySystemProvider : SystemPromptProvider {
override fun getSection(conversationId: String): String =
MemorySystemGuidance.MEMORY_GUIDANCE
}
@@ -1,16 +1,6 @@
package pw.binom.agentik.standalone.agent.memory
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()
}
@@ -1,46 +0,0 @@
package pw.binom.agentik.standalone.llm
import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteLlm
/**
* Factory для движка Google LiteRT-LM на JVM.
*
* `litert-google` опубликован только как Android AAR (с .so внутри), поэтому на JVM
* приходится вручную подгружать JNI-библиотеки LiteRT-LM, прежде чем инстанциировать
* движок. Эта функция:
*
* 1. Резолвит `pw.binom.litert.google.GoogleLiteLlm` через reflection.
* 2. Если процесс уже загрузил нативные библиотеки (`-Djava.library.path`) — успешно
* создаёт движок.
* 3. Если нет — кидает `IllegalStateException` с инструкцией по настройке.
*/
fun googleLiteLlmJvm(config: LiteConfig): LiteLlm {
val cls = try {
Class.forName("pw.binom.litert.google.GoogleLiteLlm")
} catch (e: ClassNotFoundException) {
error(
"litert-google classes are not on the classpath. " +
"Add 'pw.binom.litert:litert-google-android:6' as a runtime dependency " +
"to use the GOOGLE backend on JVM."
)
}
val ctor = cls.constructors.firstOrNull { it.parameterCount == 1 }
?: error("pw.binom.litert.google.GoogleLiteLlm constructor not found")
val engine = try {
ctor.newInstance(config)
} catch (e: UnsatisfiedLinkError) {
throw IllegalStateException(
"LiteRT-LM native libraries are not loaded. " +
"Extract .so/.dylib/.dll from litertlm-android-0.16.1.aar and pass " +
"-Djava.library.path=<dir>, or build :standalone for the androidJvm target.",
e,
)
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.targetException ?: e
}
@Suppress("UNCHECKED_CAST")
return engine as LiteLlm
}
private fun error(message: String): Nothing = throw IllegalStateException(message)
@@ -5,6 +5,7 @@ import pw.binom.litert.LiteBackend
import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteExperimental
import pw.binom.litert.LiteLlm
import pw.binom.litert.google.googleLiteLlm
import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig
import pw.binom.litert.openai.openAiLiteLlm
@@ -39,7 +40,7 @@ data class LlmConfig(
fun createLlm(): LiteLlm = when (backend) {
LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai).toLitertConfig())
LlmBackend.GOOGLE -> googleLiteLlmJvm(google!!.toLiteConfig())
LlmBackend.GOOGLE -> googleLiteLlm(google!!.toLiteConfig())
}
fun modelInfo(): String = when (backend) {
@@ -0,0 +1,59 @@
package pw.binom.agentik.standalone.persistence
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.context.ksqlite.KsqliteContextStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Bundle из 4 ksqlite-сторов для standalone-агента.
*
* Internal helper `:standalone` — bundle нужен только агенту, поэтому не
* торчит наружу через публичный API модуля. Каждый store (conversation,
* message, working_memory, reflection) живёт в своём ksqlite-модуле;
* этот класс собирает их вокруг одной shared-connection и закрывает их
* в правильном порядке в [close].
*
* Lifecycle: открывает [SQLiteConnection] и возвращает 4 store'а. Каждый
* store сам прогоняет свою схему в конструкторе (`Schema.migrate(connection)`
* — idempotent `CREATE TABLE IF NOT EXISTS`), явных вызовов миграции в bundle
* нет. Caller ДОЛЖЕН вызвать [close] при завершении.
*
* Закрытие в правильном порядке: зависимые сторы → владелец connection.
*
* @param path путь к .db файлу, либо URI для in-memory/shared-cache.
*/
internal class SqliteStores internal constructor(
val connection: SQLiteConnection,
val conversations: MutableConversationStore,
val messages: MutableJournalStore,
val workingMemory: ContextStore,
val reflections: ReflectionStore,
) : AutoCloseable {
override fun close() {
conversations.close()
messages.close()
workingMemory.close()
reflections.close()
connection.close()
}
companion object {
fun open(path: String): SqliteStores = assemble(SQLiteConnection.open(path))
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
private fun assemble(conn: SQLiteConnection): SqliteStores = SqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteJournalStore(conn),
workingMemory = KsqliteContextStore(conn),
reflections = KsqliteReflectionStore(conn),
)
}
}
@@ -4,8 +4,8 @@ import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.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

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