From e447525059f092323c963d70c4c30eccdfda0bcf Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 23 Sep 2026 03:48:38 +0300 Subject: [PATCH] Remove `:storage-inmemory` module, tests, and related code. --- README.md | 11 +- STATUS.txt | 37 --- agent-api/build.gradle.kts | 42 +++ .../pw/binom/agentik/agent/Component.kt | 46 +++ .../binom/agentik/agent/ConversationHandle.kt | 49 +++ .../pw/binom/agentik/agent/MutableAgent.kt | 78 +++++ .../agentik/agent/SystemPromptProvider.kt | 29 ++ .../pw/binom/agentik/agent/ToolProvider.kt | 33 ++ agent-toolsets/build.gradle.kts | 3 + .../agentik/toolsets/DisableToolsetTool.kt | 24 +- .../agentik/toolsets/EnableToolsetTool.kt | 38 +-- .../pw/binom/agentik/toolsets/NamedTool.kt | 15 - .../pw/binom/agentik/toolsets/SyncLiteTool.kt | 46 ++- .../agentik/toolsets/ToolsetComponent.kt | 103 ++++++ .../toolsets/DisableToolsetToolTest.kt | 17 +- .../agentik/toolsets/EnableToolsetToolTest.kt | 16 +- .../toolsets/ToolsetDispatchPolicyTest.kt | 1 + .../agentik/toolsets/ToolsetRegistryTest.kt | 1 + build.gradle.kts | 4 +- client/README.md | 2 +- context-ksqlite/build.gradle.kts | 2 +- .../context/ksqlite/KsqliteContextStore.kt | 12 +- .../binom/agentik/context/ksqlite/Schema.kt | 2 +- gradle/libs.versions.toml | 8 +- journal-ksqlite/build.gradle.kts | 2 +- .../journal/ksqlite/KsqliteJournalStore.kt | 10 +- .../KsqliteMutableConversationStore.kt | 4 +- .../agentik/journal/ksqlite/MessageCodecs.kt | 6 +- .../binom/agentik/journal/ksqlite/Schema.kt | 2 +- .../KsqliteMutableConversationStoreTest.kt | 146 +++++++++ .../journal/ksqlite/SchemaMigrationTest.kt | 142 ++++++++ llm-tools/build.gradle.kts | 23 +- .../binom/agentik/llm/tools/LlmReflector.kt | 2 +- .../pw/binom/agentik/llm/tools/FakeLiteLlm.kt | 127 +++++++ .../llm/tools}/ReflectionParserTest.kt | 2 +- .../tools}/memory/LlmMemoryReviewerTest.kt | 4 +- .../tools}/memory/ReviewDecisionParserTest.kt | 2 +- mcp-bridge/README.md | 56 ++++ mcp-bridge/build.gradle.kts | 8 +- .../agentik/mcp/bridge/McpBridgeComponent.kt | 61 ++++ .../binom/agentik/mcp/bridge/McpRegistry.kt | 10 +- .../agentik/reflection/ReflectionStore.kt | 5 +- reflection-inmemory/build.gradle.kts | 36 ++ .../inmemory/InMemoryReflectionStore.kt | 4 +- .../inmemory/InMemoryReflectionStoreTest.kt | 95 ++++++ reflection-ksqlite/build.gradle.kts | 38 +++ .../ksqlite/KsqliteReflectionStore.kt | 4 +- .../agentik/reflection/ksqlite/Schema.kt | 71 ++++ .../ksqlite/KsqliteReflectionStoreTest.kt | 49 ++- .../reflection/ksqlite/SchemaMigrationTest.kt | 64 ++++ settings.gradle.kts | 50 +-- skill-mining/build.gradle.kts | 44 +++ .../binom/agentik/skill/mining}/SkillMiner.kt | 4 +- .../skill/mining/SkillMiningComponent.kt | 154 +++++++++ .../skill/mining}/SkillMiningParser.kt | 2 +- .../skill/mining}/SkillMiningPrompts.kt | 2 +- .../agentik/skill/mining}/SkillReadTool.kt | 4 +- .../binom/agentik/skill/mining}/SkillTools.kt | 29 +- .../binom/agentik/skill/mining/FakeLiteLlm.kt | 127 +++++++ .../agentik/skill/mining}/SkillMinerTest.kt | 4 +- .../skill/mining}/SkillMiningParserTest.kt | 4 +- .../skill/mining}/SkillReadToolTest.kt | 3 +- standalone/build.gradle.kts | 19 +- .../binom/agentik/standalone/DebugRoutes.kt | 2 +- .../pw/binom/agentik/standalone/Main.kt | 13 +- .../standalone/agent/BackgroundScheduler.kt | 2 +- .../agentik/standalone/agent/ChatAgent.kt | 310 ++++++++++++++---- .../standalone/agent/CompactionCoordinator.kt | 48 ++- .../standalone/agent/ConversationLoop.kt | 68 ++-- .../standalone/agent/ConversationState.kt | 4 +- .../standalone/agent/SkillToolsFactory.kt | 22 -- .../standalone/agent/ToolDispatcher.kt | 13 +- .../agent/memory/MemoryComponent.kt | 52 +++ .../standalone/agent/memory/MemoryTools.kt | 200 ++++------- .../agent/memory/MemoryToolsFactory.kt | 31 +- .../standalone/agent/soul/SoulComponent.kt | 36 ++ .../standalone/persistence/SqliteStores.kt | 59 ++++ .../agent/ChatAgentReflectionTest.kt | 23 +- .../agentik/standalone/agent/ChatAgentTest.kt | 86 ++--- .../standalone/agent/ChatAgentToolsetsTest.kt | 22 +- .../standalone/agent/CompactionTest.kt | 6 +- .../agentik/standalone/agent/FakeLiteLlm.kt | 9 +- .../standalone/agent/InMemorySkillStore.kt | 28 ++ .../standalone/agent/MemoryWiringTest.kt | 16 +- .../standalone/agent/SkillToolsTest.kt | 2 + .../agentik/standalone/mcp/McpRegistryTest.kt | 13 +- .../standalone/persistence/PersistenceTest.kt | 6 +- storage-inmemory/README.md | 55 ---- storage-inmemory/build.gradle.kts | 33 -- .../storage/inmemory/InMemoryMessageStore.kt | 49 --- .../storage/inmemory/InMemoryStorage.kt | 37 --- .../inmemory/InMemoryWorkingMemoryStore.kt | 90 ----- .../inmemory/InMemoryMessageStoreTest.kt | 89 ----- .../inmemory/InMemoryReflectionStoreTest.kt | 118 ------- .../InMemoryWorkingMemoryStoreTest.kt | 106 ------ storage-ksqlite/build.gradle.kts | 54 --- .../agentik/storage/ksqlite/KsqliteStores.kt | 55 ---- .../ksqlite/KsqliteWorkingMemoryStore.kt | 225 ------------- .../agentik/storage/ksqlite/MessageCodecs.kt | 89 ----- .../binom/agentik/storage/ksqlite/Schema.kt | 116 ------- .../ksqlite/KsqliteWorkingMemoryStoreTest.kt | 87 ----- .../storage/ksqlite/SchemaMigrationTest.kt | 114 ------- vector-index-api/README.md | 74 +++++ vector-index-jvector/README.md | 98 ++++++ vector-index-ksqlite/README.md | 112 +++++++ 105 files changed, 2732 insertions(+), 1978 deletions(-) delete mode 100644 STATUS.txt create mode 100644 agent-api/build.gradle.kts create mode 100644 agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/Component.kt create mode 100644 agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ConversationHandle.kt create mode 100644 agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/MutableAgent.kt create mode 100644 agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/SystemPromptProvider.kt create mode 100644 agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ToolProvider.kt delete mode 100644 agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/NamedTool.kt create mode 100644 agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetComponent.kt create mode 100644 journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStoreTest.kt create mode 100644 journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/SchemaMigrationTest.kt create mode 100644 llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/FakeLiteLlm.kt rename {standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent => llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools}/ReflectionParserTest.kt (98%) rename {standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent => llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools}/memory/LlmMemoryReviewerTest.kt (98%) rename {standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent => llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools}/memory/ReviewDecisionParserTest.kt (98%) create mode 100644 mcp-bridge/README.md create mode 100644 mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpBridgeComponent.kt create mode 100644 reflection-inmemory/build.gradle.kts rename {storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage => reflection-inmemory/src/commonMain/kotlin/pw/binom/agentik/reflection}/inmemory/InMemoryReflectionStore.kt (96%) create mode 100644 reflection-inmemory/src/commonTest/kotlin/pw/binom/agentik/reflection/inmemory/InMemoryReflectionStoreTest.kt create mode 100644 reflection-ksqlite/build.gradle.kts rename {storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage => reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection}/ksqlite/KsqliteReflectionStore.kt (98%) create mode 100644 reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection/ksqlite/Schema.kt rename {storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage => reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection}/ksqlite/KsqliteReflectionStoreTest.kt (52%) create mode 100644 reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection/ksqlite/SchemaMigrationTest.kt create mode 100644 skill-mining/build.gradle.kts rename {llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools => skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining}/SkillMiner.kt (97%) create mode 100644 skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt rename {llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools => skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining}/SkillMiningParser.kt (99%) rename {llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools => skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining}/SkillMiningPrompts.kt (99%) rename {standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent => skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining}/SkillReadTool.kt (97%) rename {standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent => skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining}/SkillTools.kt (83%) create mode 100644 skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/FakeLiteLlm.kt rename {standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent => skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining}/SkillMinerTest.kt (96%) rename {standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent => skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining}/SkillMiningParserTest.kt (96%) rename {standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent => skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining}/SkillReadToolTest.kt (91%) delete mode 100644 standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt create mode 100644 standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryComponent.kt create mode 100644 standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/soul/SoulComponent.kt create mode 100644 standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt create mode 100644 standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/InMemorySkillStore.kt delete mode 100644 storage-inmemory/README.md delete mode 100644 storage-inmemory/build.gradle.kts delete mode 100644 storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStore.kt delete mode 100644 storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryStorage.kt delete mode 100644 storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStore.kt delete mode 100644 storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStoreTest.kt delete mode 100644 storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryReflectionStoreTest.kt delete mode 100644 storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStoreTest.kt delete mode 100644 storage-ksqlite/build.gradle.kts delete mode 100644 storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteStores.kt delete mode 100644 storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStore.kt delete mode 100644 storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/MessageCodecs.kt delete mode 100644 storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/Schema.kt delete mode 100644 storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStoreTest.kt delete mode 100644 storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/SchemaMigrationTest.kt create mode 100644 vector-index-api/README.md create mode 100644 vector-index-jvector/README.md create mode 100644 vector-index-ksqlite/README.md diff --git a/README.md b/README.md index a41e7d8..2f0f3cb 100644 --- a/README.md +++ b/README.md @@ -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) — тулы и диспетчер. ## Где смотреть версии diff --git a/STATUS.txt b/STATUS.txt deleted file mode 100644 index d9a2ca1..0000000 --- a/STATUS.txt +++ /dev/null @@ -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. diff --git a/agent-api/build.gradle.kts b/agent-api/build.gradle.kts new file mode 100644 index 0000000..6775e29 --- /dev/null +++ b/agent-api/build.gradle.kts @@ -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) + } + } +} diff --git a/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/Component.kt b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/Component.kt new file mode 100644 index 0000000..0c6adaa --- /dev/null +++ b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/Component.kt @@ -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) +} diff --git a/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ConversationHandle.kt b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ConversationHandle.kt new file mode 100644 index 0000000..fd4c8cf --- /dev/null +++ b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ConversationHandle.kt @@ -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 + + 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 diff --git a/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/MutableAgent.kt b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/MutableAgent.kt new file mode 100644 index 0000000..1e085c0 --- /dev/null +++ b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/MutableAgent.kt @@ -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 + + /** + * Провайдеры tools, регистрируются компонентами через [install]. + * [ToolProvider.tools] вызывается при формировании набора тулов + * для конкретной беседы; компонент решает сам, какие тулы отдавать + * (например, разворачивая skill-каталог в `read_skill` / `skill_save`). + */ + val toolProviders: MutableList + + /** + * Устанавливает [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() +} diff --git a/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/SystemPromptProvider.kt b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/SystemPromptProvider.kt new file mode 100644 index 0000000..9b4f22a --- /dev/null +++ b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/SystemPromptProvider.kt @@ -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 +} diff --git a/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ToolProvider.kt b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ToolProvider.kt new file mode 100644 index 0000000..651ee84 --- /dev/null +++ b/agent-api/src/commonMain/kotlin/pw/binom/agentik/agent/ToolProvider.kt @@ -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 +} diff --git a/agent-toolsets/build.gradle.kts b/agent-toolsets/build.gradle.kts index 39dfd5d..61013f0 100644 --- a/agent-toolsets/build.gradle.kts +++ b/agent-toolsets/build.gradle.kts @@ -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) diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/DisableToolsetTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/DisableToolsetTool.kt index 7056bae..3ebd8b8 100644 --- a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/DisableToolsetTool.kt +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/DisableToolsetTool.kt @@ -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( + 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() } } diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/EnableToolsetTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/EnableToolsetTool.kt index e37f989..7bbd914 100644 --- a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/EnableToolsetTool.kt +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/EnableToolsetTool.kt @@ -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( + 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() diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/NamedTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/NamedTool.kt deleted file mode 100644 index e53f41a..0000000 --- a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/NamedTool.kt +++ /dev/null @@ -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) diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SyncLiteTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SyncLiteTool.kt index b593a08..84dde2f 100644 --- a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SyncLiteTool.kt +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SyncLiteTool.kt @@ -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(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 liteToolSuspend( + name: String, + description: String = "", + noinline handler: suspend (Args) -> String, +): LiteTool = liteTool( + 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 +} diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetComponent.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetComponent.kt new file mode 100644 index 0000000..0c1ac57 --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetComponent.kt @@ -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, +) : 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, +) : ToolProvider { + + override fun getTools(conversationId: String): List = 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 = 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) ?: "" + } +} diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/DisableToolsetToolTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/DisableToolsetToolTest.kt index e4f63bf..2559385 100644 --- a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/DisableToolsetToolTest.kt +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/DisableToolsetToolTest.kt @@ -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) - } } diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/EnableToolsetToolTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/EnableToolsetToolTest.kt index 74ef744..01412df 100644 --- a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/EnableToolsetToolTest.kt +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/EnableToolsetToolTest.kt @@ -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) - } } diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicyTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicyTest.kt index 16dcfc3..5ddeca6 100644 --- a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicyTest.kt +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicyTest.kt @@ -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 } diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetRegistryTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetRegistryTest.kt index fe6b7fa..e3117fd 100644 --- a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetRegistryTest.kt +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetRegistryTest.kt @@ -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" } diff --git a/build.gradle.kts b/build.gradle.kts index 5585dd6..3e9969a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,8 +47,8 @@ val moduleDescriptions: Map = 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)." diff --git a/client/README.md b/client/README.md index c9713f9..57801aa 100644 --- a/client/README.md +++ b/client/README.md @@ -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 diff --git a/context-ksqlite/build.gradle.kts b/context-ksqlite/build.gradle.kts index 56c74e7..5493fe2 100644 --- a/context-ksqlite/build.gradle.kts +++ b/context-ksqlite/build.gradle.kts @@ -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) diff --git a/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/KsqliteContextStore.kt b/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/KsqliteContextStore.kt index eb6385f..07d07b4 100644 --- a/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/KsqliteContextStore.kt +++ b/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/KsqliteContextStore.kt @@ -17,15 +17,9 @@ import kotlinx.coroutines.withContext /** * ksqlite-реализация [ContextStore] (таблица `working_memory`). * - * Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore] - * из `:storage-ksqlite`, но: - * - лежит в собственном модуле `:context-ksqlite`; - * - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`, - * теперь главный класс — `ContextStore`); сами типы строк - * [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались. - * - * ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске — - * это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно. + * Единственный владелец таблицы `working_memory` в проекте. Используется + * напрямую через `:context-ksqlite` зависимость; bundle'ом собирает + * `pw.binom.agentik.standalone.persistence.SqliteStores`. * * ## Lifecycle соединения * diff --git a/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/Schema.kt b/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/Schema.kt index abf7caf..ca43562 100644 --- a/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/Schema.kt +++ b/context-ksqlite/src/commonMain/kotlin/pw/binom/agentik/context/ksqlite/Schema.kt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adcc949..45b4740 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/journal-ksqlite/build.gradle.kts b/journal-ksqlite/build.gradle.kts index 501a368..ce39d06 100644 --- a/journal-ksqlite/build.gradle.kts +++ b/journal-ksqlite/build.gradle.kts @@ -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) diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt index cc2e0fe..413f69d 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteJournalStore.kt @@ -14,15 +14,15 @@ import kotlinx.coroutines.withContext /** * ksqlite-реализация [MutableJournalStore] (append-only audit log). * - * Миграция завершена: это ЕДИНСТВЕННЫЙ класс для message-таблицы. Параллельная - * копия `:storage-ksqlite/KsqliteMessageStore.kt` удалена вместе со своим - * тестом (consumer `KsqliteStores.assemble()` уже мигрировал на этот класс). + * Единственный класс для message-таблицы. Используется напрямую через + * `:journal-ksqlite` зависимость; bundle'ом собирает + * `pw.binom.agentik.standalone.persistence.SqliteStores`. * * ## Lifecycle соединения * * Три формы конструктора с разной семантикой владения: * - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает - * его в [close]. Для shared-connection bundles (`KsqliteStores.assemble`), + * его в [close]. Для shared-connection bundles (`SqliteStores.assemble`), * где один connection используется многими store'ами и закрывается bundle'ом. * - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает * его в [close]. @@ -63,7 +63,7 @@ class KsqliteJournalStore private constructor( /** * Внешнее соединение — store НЕ закрывает его в [close]. Для - * shared-connection bundles (`KsqliteStores.assemble`), где один + * shared-connection bundles (`SqliteStores.assemble`), где один * connection используется многими store'ами и закрывается bundle'ом. */ constructor(connection: SQLiteConnection) : this( diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStore.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStore.kt index 37a7627..a2e21c8 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStore.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStore.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.withContext * Семантика владения connection'ом идентична [KsqliteJournalStore]: * - `KsqliteMutableConversationStore(connection)` — внешнее соединение, * store НЕ закрывает его в [close] (используется shared-connection - * bundle'ом `KsqliteStores.assemble`). + * bundle'ом `SqliteStores.assemble`). * - `KsqliteMutableConversationStore(path)` — открывает файловое соединение, * закрывает его в [close]. * - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает @@ -47,7 +47,7 @@ class KsqliteMutableConversationStore private constructor( /** * Внешнее соединение — store НЕ закрывает его в [close]. Для - * shared-connection bundles (`KsqliteStores.assemble`). + * shared-connection bundles (`SqliteStores.assemble`). */ constructor(connection: SQLiteConnection) : this( connection = connection, diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt index 9567b1f..ad0e5f5 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/MessageCodecs.kt @@ -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 = when (record) { is MessageRecord.UserMessage -> "user" to encodeBodyPayload( diff --git a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt index ea6d171..3eb7a7e 100644 --- a/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt +++ b/journal-ksqlite/src/commonMain/kotlin/pw/binom/agentik/journal/ksqlite/Schema.kt @@ -82,7 +82,7 @@ object Schema { * user_version как gate перестал работать — два модуля ставят его в 1, * второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL * idempotently; координация multi-module миграций — ответственность - * вызывающего (см. `KsqliteStores.open()` в `:storage-ksqlite`). + * вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`). */ fun migrate(conn: SQLiteConnection) { conn.exec("BEGIN") diff --git a/journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStoreTest.kt b/journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStoreTest.kt new file mode 100644 index 0000000..48ff465 --- /dev/null +++ b/journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/KsqliteMutableConversationStoreTest.kt @@ -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() + } + } +} diff --git a/journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/SchemaMigrationTest.kt b/journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/SchemaMigrationTest.kt new file mode 100644 index 0000000..96648eb --- /dev/null +++ b/journal-ksqlite/src/commonTest/kotlin/pw/binom/agentik/journal/ksqlite/SchemaMigrationTest.kt @@ -0,0 +1,142 @@ +package pw.binom.agentik.journal.ksqlite + +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.journal.Content +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.MessageRecord +import pw.binom.db.ksqlite.SQLiteConnection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Тесты на Schema.migrate() в `:journal-ksqlite`: + * - fresh DB → создаются `conversation` + `message` + индексы + * (`idx_conv_updated`, `idx_msg_conv`); + * - уже мигрированная БД → migrate() идемпотентен (no-op); + * - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores), + * migrate() приводит её в боевое состояние; + * - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear + * делают full-scan по message. + * + * `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` — + * в `pw.binom.agentik.reflection.ksqlite`. + */ +class SchemaMigrationTest { + + @Test + fun `fresh DB gets conversation and message tables and indexes`() = runTest { + val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}") + try { + Schema.migrate(conn) + + for (table in listOf( + Schema.TABLE_CONVERSATION, + Schema.TABLE_MESSAGE, + )) { + assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()") + } + + for (index in listOf( + Schema.IDX_CONV_UPDATED, + Schema.IDX_MSG_CONV, + )) { + assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()") + } + } finally { + conn.close() + } + } + + @Test + fun `migrate is idempotent on already-migrated DB`() = runTest { + val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}") + try { + Schema.migrate(conn) + // повторный вызов не должен ни упасть, ни пересоздать таблицы + // (CREATE IF NOT EXISTS — no-op) + Schema.migrate(conn) + Schema.migrate(conn) + assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION)) + assertTrue(tableExists(conn, Schema.TABLE_MESSAGE)) + } finally { + conn.close() + } + } + + @Test + fun `raw SQLiteConnection plus migrate gives working stores`() = runTest { + val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}") + Schema.migrate(conn) + + val convStore = KsqliteMutableConversationStore(conn) + val msgStore = KsqliteJournalStore(conn) + try { + convStore.upsert( + ConversationRecord( + id = "c1", title = "t", isTemporal = false, + createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), + updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), + ) + ) + msgStore.append( + MessageRecord.UserMessage( + id = "m1", conversationId = "c1", + content = listOf(Content.Text("hi")), + createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"), + ) + ) + val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10) + assertEquals(1, got.size) + assertEquals("m1", got[0].id) + } finally { + convStore.close() + msgStore.close() + } + } + + @Test + fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest { + val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}") + try { + Schema.migrate(conn) + val cols = indexColumns(conn, Schema.IDX_MSG_CONV) + assertEquals(listOf(Schema.COL_CONVERSATION_ID, Schema.COL_CREATED_AT), cols) + } finally { + conn.close() + } + } + + private fun tableExists(conn: SQLiteConnection, name: String): Boolean { + conn.prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?" + ).use { stmt -> + stmt.bindText(1, name) + stmt.executeQuery().use { rs -> return rs.next() } + } + } + + private fun indexExists(conn: SQLiteConnection, name: String): Boolean { + conn.prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?" + ).use { stmt -> + stmt.bindText(1, name) + stmt.executeQuery().use { rs -> return rs.next() } + } + } + + private fun indexColumns(conn: SQLiteConnection, indexName: String): List { + // PRAGMA index_info возвращает одну строку на колонку индекса + // (seqno, cid, name). Параметризовать через `?` нельзя — собираем + // строку (name — контролируемая константа, не user input). + val cols = mutableListOf() + conn.prepare("PRAGMA index_info($indexName)").use { stmt -> + stmt.executeQuery().use { rs -> + while (rs.next()) { + rs.getText(2)?.let(cols::add) + } + } + } + return cols + } +} diff --git a/llm-tools/build.gradle.kts b/llm-tools/build.gradle.kts index 8970ed1..b7ecdae 100644 --- a/llm-tools/build.gradle.kts +++ b/llm-tools/build.gradle.kts @@ -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")) } } } diff --git a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmReflector.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmReflector.kt index e610885..f432163 100644 --- a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmReflector.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmReflector.kt @@ -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, ) { /** diff --git a/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/FakeLiteLlm.kt b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/FakeLiteLlm.kt new file mode 100644 index 0000000..f3fe166 --- /dev/null +++ b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/FakeLiteLlm.kt @@ -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>) : 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 = mutableListOf() + + var lastConfig: LiteConversationConfig? = null + var lastContents: List? = null + val conversations = mutableListOf() + + 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 = + 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 = config.initialMessages + private val mutableHistory: MutableList = config.initialMessages.toMutableList() + override val history: List get() = mutableHistory.toList() + override var systemInstruction: String? = config.systemInstruction + override var tools: List = config.tools + + override fun sendStream(prompt: String): Flow = + sendStreamContents(listOf(LiteContentPart.Text(prompt))) + + override fun sendStreamContents(contents: List): Flow { + 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) { + 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): 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() {} +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/ReflectionParserTest.kt similarity index 98% rename from standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt rename to llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/ReflectionParserTest.kt index 001ccf4..e8fd2d2 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt +++ b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/ReflectionParserTest.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools import kotlin.test.Test import kotlin.test.assertEquals diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/memory/LlmMemoryReviewerTest.kt similarity index 98% rename from standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt rename to llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/memory/LlmMemoryReviewerTest.kt index fb89e8a..a43fb71 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt +++ b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/memory/LlmMemoryReviewerTest.kt @@ -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 diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/memory/ReviewDecisionParserTest.kt similarity index 98% rename from standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt rename to llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/memory/ReviewDecisionParserTest.kt index e45c32a..8b2f0a1 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt +++ b/llm-tools/src/commonTest/kotlin/pw/binom/agentik/llm/tools/memory/ReviewDecisionParserTest.kt @@ -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 diff --git a/mcp-bridge/README.md b/mcp-bridge/README.md new file mode 100644 index 0000000..69df690 --- /dev/null +++ b/mcp-bridge/README.md @@ -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) — пока нет. diff --git a/mcp-bridge/build.gradle.kts b/mcp-bridge/build.gradle.kts index 1c3d71b..3f1338c 100644 --- a/mcp-bridge/build.gradle.kts +++ b/mcp-bridge/build.gradle.kts @@ -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) diff --git a/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpBridgeComponent.kt b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpBridgeComponent.kt new file mode 100644 index 0000000..0ef96d6 --- /dev/null +++ b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpBridgeComponent.kt @@ -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 = registry.allTools + } +} + +val McpRegistry.component + get() = McpBridgeComponent(this) diff --git a/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpRegistry.kt b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpRegistry.kt index 8d39b47..c805089 100644 --- a/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpRegistry.kt +++ b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpRegistry.kt @@ -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 by lazy { - allTools.filterIsInstance().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() diff --git a/reflection-api/src/commonMain/kotlin/pw/binom/agentik/reflection/ReflectionStore.kt b/reflection-api/src/commonMain/kotlin/pw/binom/agentik/reflection/ReflectionStore.kt index 4763e7d..8db10d3 100644 --- a/reflection-api/src/commonMain/kotlin/pw/binom/agentik/reflection/ReflectionStore.kt +++ b/reflection-api/src/commonMain/kotlin/pw/binom/agentik/reflection/ReflectionStore.kt @@ -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` для тестов / dev * * Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup) * или архивируются через [Curator]-подобный процесс, но не редактируются. diff --git a/reflection-inmemory/build.gradle.kts b/reflection-inmemory/build.gradle.kts new file mode 100644 index 0000000..d4d3e0d --- /dev/null +++ b/reflection-inmemory/build.gradle.kts @@ -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) + } + } +} diff --git a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryReflectionStore.kt b/reflection-inmemory/src/commonMain/kotlin/pw/binom/agentik/reflection/inmemory/InMemoryReflectionStore.kt similarity index 96% rename from storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryReflectionStore.kt rename to reflection-inmemory/src/commonMain/kotlin/pw/binom/agentik/reflection/inmemory/InMemoryReflectionStore.kt index b8bc396..1472316 100644 --- a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryReflectionStore.kt +++ b/reflection-inmemory/src/commonMain/kotlin/pw/binom/agentik/reflection/inmemory/InMemoryReflectionStore.kt @@ -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 в самой структуре — JSON-сериализация * делается на уровне SQLDelight в SQLite-импле; здесь просто держим в памяти. diff --git a/reflection-inmemory/src/commonTest/kotlin/pw/binom/agentik/reflection/inmemory/InMemoryReflectionStoreTest.kt b/reflection-inmemory/src/commonTest/kotlin/pw/binom/agentik/reflection/inmemory/InMemoryReflectionStoreTest.kt new file mode 100644 index 0000000..22f9f70 --- /dev/null +++ b/reflection-inmemory/src/commonTest/kotlin/pw/binom/agentik/reflection/inmemory/InMemoryReflectionStoreTest.kt @@ -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 = 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 + } +} diff --git a/reflection-ksqlite/build.gradle.kts b/reflection-ksqlite/build.gradle.kts new file mode 100644 index 0000000..46636ac --- /dev/null +++ b/reflection-ksqlite/build.gradle.kts @@ -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) + } + } +} diff --git a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteReflectionStore.kt b/reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection/ksqlite/KsqliteReflectionStore.kt similarity index 98% rename from storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteReflectionStore.kt rename to reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection/ksqlite/KsqliteReflectionStore.kt index 4c46869..d7d5740 100644 --- a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteReflectionStore.kt +++ b/reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection/ksqlite/KsqliteReflectionStore.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.storage.ksqlite +package pw.binom.agentik.reflection.ksqlite import pw.binom.agentik.reflection.Reflection import pw.binom.agentik.reflection.ReflectionEvent @@ -209,7 +209,7 @@ class KsqliteReflectionStore private constructor( ) } -/** Hand-rolled JSON-encode/decode для List — синхронизировать с :storage-sqlite. */ +/** Hand-rolled JSON-encode/decode для List. */ internal fun encodeStringArray(items: List): String = buildString { append('[') items.forEachIndexed { i, s -> diff --git a/reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection/ksqlite/Schema.kt b/reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection/ksqlite/Schema.kt new file mode 100644 index 0000000..59b3cfc --- /dev/null +++ b/reflection-ksqlite/src/commonMain/kotlin/pw/binom/agentik/reflection/ksqlite/Schema.kt @@ -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 + } + } +} diff --git a/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteReflectionStoreTest.kt b/reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection/ksqlite/KsqliteReflectionStoreTest.kt similarity index 52% rename from storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteReflectionStoreTest.kt rename to reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection/ksqlite/KsqliteReflectionStoreTest.kt index 0cf6b20..9d0da4b 100644 --- a/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteReflectionStoreTest.kt +++ b/reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection/ksqlite/KsqliteReflectionStoreTest.kt @@ -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()) } } diff --git a/reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection/ksqlite/SchemaMigrationTest.kt b/reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection/ksqlite/SchemaMigrationTest.kt new file mode 100644 index 0000000..a012e41 --- /dev/null +++ b/reflection-ksqlite/src/commonTest/kotlin/pw/binom/agentik/reflection/ksqlite/SchemaMigrationTest.kt @@ -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() } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a1d0a0d..fed9baf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,7 +34,7 @@ include(":standalone") include(":llm-tools") // Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены // из :standalone — MCP не специфичен для standalone'а, это generic мост -// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool). +// между MCP-SDK и LiteTool. include(":mcp-bridge") // Собственный протокол agentik. Пока в нём пилим, потом вынесем. include(":proto") @@ -61,18 +61,15 @@ include(":memory-md") // для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART // работает через Java 11 base classes (scalar fallback). include(":memory-vector") -// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore / -// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации — -// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred). -// In-memory реализация store'ов из :journal-api и :context-api. KMP, -// IO-зависимостей. Используется в тестах (быстрый setup, без JDBC) и будет -// использоваться в Android-сборке (JVector/SQLite не подходят для ART out-of-box). +// Интерфейсы хранилища и разговорной истории. Контракты распределены по +// доменам: :journal-api (MutableJournalStore/MutableConversationStore), +// :context-api (ContextStore), :reflection-api (ReflectionStore). +// Реализации — :journal-ksqlite/:context-ksqlite/:reflection-ksqlite (ksqlite) +// и :journal-inmemory/:reflection-inmemory (in-memory). //include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids) // и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids) // Модуль удалён с диска. include(":reflection-api") -//include(":message-log-api") -//include(":message-log-ksqlite") // Новые API-модули трёх сущностей (canonical имена): // - :journal-api — append-only audit log (бывший :message-log-api) // - :context-api — то что видит LLM (бывший :working-memory-api) @@ -97,7 +94,6 @@ include(":journal-inmemory") include(":memory-md-vector") //include(":event-store-in-memory") //include(":working-memory-api") -include(":storage-inmemory") // SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only // KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite). // Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector @@ -105,24 +101,20 @@ include(":storage-inmemory") // Linux — собираются локально на macOS. Пока покрывает только EventStore; // остальные store'ы мигрируют после стабилизации ksqlite и реального // использования на Android-агенте. -include(":storage-ksqlite") // ksqlite-реализация :context-api (ContextStore / working_memory table). // Минимальный модуль: только таблица `working_memory` + 2 индекса. -// Параллельно существует :storage-ksqlite/KsqliteWorkingMemoryStore.kt — -// миграция consumers'ов по чуть-чуть, отдельно. include(":context-ksqlite") -// ksqlite-реализация :journal-api (JournalStore / message table). -// Минимальный модуль: только таблица `message` + 1 индекс -// `(conversation_id, created_at)`. Параллельно существует -// :storage-ksqlite/KsqliteMessageStore.kt — миграция consumers'ов -// по чуть-чуть, отдельно. +// ksqlite-реализация :journal-api (JournalStore / message table, +// ConversationStore / conversation table). Минимальный модуль: +// таблицы `message` + `conversation` + индексы. include(":journal-ksqlite") -// KMP-реализация EventStore через ksqlite (https://github.com/caffeine-mgn/ksqlite). -// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector -// на KMP-таргетах (JVM + linuxX64 + mingwX64). Apple targets auto-disabled на -// Linux — собираются локально на macOS. На этом этапе покрывает только EventStore; -// остальные store'ы мигрируют после стабилизации ksqlite (>=0.2) и реального -// использования на Android-агенте. +// ksqlite-реализация :reflection-api (ReflectionStore / reflection table). +// Минимальный модуль: только таблица `reflection` + 2 индекса. +include(":reflection-ksqlite") +// in-memory реализация :reflection-api (ReflectionStore). Используется в +// тестах, dev-режиме и embedded-сценариях. Чистый KMP (9 таргетов), +// никакого I/O. +include(":reflection-inmemory") // Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные // тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть // переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+. @@ -138,3 +130,13 @@ include(":vector-index-jvector") // SQLite БД. Подходит для small-to-medium масштабов; для больших — sqlite-vec // или JVector (выше). include(":vector-index-ksqlite") +// Расширяемый слой поверх :proto: `MutableAgent` (наследник Agent + install/uninstall +// компонентов), `Component` (install(MutableAgent)/uninstall(MutableAgent)) и три +// capability-провайдера (system-prompt, tools, event-listener). Каждый компонент +// сам регистрирует свои capability в MutableAgent при install и снимает их при +// uninstall. KMP, все 9 целей; чистый API-слой, без реализаций. +include(":agent-api") +// Skill-подсистема как компонент: тулы skill_save/skill_delete/skill_read + +// SkillMiningComponent, слушающий BackgroundEventBus и добывающий новые скилы +// через SkillMiner на Closing/Compaction. +include(":skill-mining") diff --git a/skill-mining/build.gradle.kts b/skill-mining/build.gradle.kts new file mode 100644 index 0000000..1706d77 --- /dev/null +++ b/skill-mining/build.gradle.kts @@ -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) + } + } +} diff --git a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiner.kt b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiner.kt similarity index 97% rename from llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiner.kt rename to skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiner.kt index ccff70a..1af36d3 100644 --- a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiner.kt +++ b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiner.kt @@ -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]. diff --git a/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt new file mode 100644 index 0000000..3389baa --- /dev/null +++ b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningComponent.kt @@ -0,0 +1,154 @@ +package pw.binom.agentik.skill.mining + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.agent.Component +import pw.binom.agentik.agent.ConversationAware +import pw.binom.agentik.agent.ConversationHandle +import pw.binom.agentik.agent.MutableAgent +import pw.binom.agentik.agent.SystemPromptProvider +import pw.binom.agentik.agent.ToolProvider +import pw.binom.agentik.skill.mining.SkillMiner +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillStore +import pw.binom.agentik.skills.renderSystemPromptSection +import pw.binom.litert.LiteLlm +import pw.binom.litert.LiteTool + +/** + * События, по которым SkillMiningComponent решает, что пора майнить новые скилы. + * + * Standalone-часть мэпит свой [pw.binom.agentik.standalone.agent.BackgroundEventBus] + * на этот sealed interface и подаёт результат в [SkillMiningComponent.events]. + * Делаем так, чтобы модуль :skill-mining не зависел от :standalone и его + * внутренних типов. + */ +sealed interface SkillMiningEvent { + val conversationId: String + + /** Разговор закрывается — последний шанс вытащить скилы из контекста. */ + data class ConversationClosing(override val conversationId: String) : SkillMiningEvent + + /** Сработал compactor — старые turn'ы ушли, mining нужен на новом контексте. */ + data class ConversationCompacted(override val conversationId: String) : SkillMiningEvent +} + +/** + * Полная подсистема скилов как один Component: + * 1. Тулы skill_save / skill_delete / skill_read — через [SkillToolProvider]. + * 2. Секция системного промпта с актуальным каталогом — через [SkillSystemProvider]. + * 3. Фоновый майнер — на [SkillMiningEvent.ConversationClosing] / + * [SkillMiningEvent.ConversationCompacted] вызывает [SkillMiner] над + * последними turn'ами разговора и пишет новые скилы в [SkillStore]. + * + * Поведение майнера зависит от per-conversation state ([ConversationHandle]): + * - isTemporal=true → пропускаем (mining не имеет смысла для temp-бесед); + * - recentTurns(limit) → что майнер видит. + * + * Подключается как обычный Component: `agent.install(SkillMiningComponent(...))`. + * Дополнительно компонент реализует [ConversationAware] — [MutableAgent] + * оповещает его о создании/закрытии каждого разговора. + */ +class SkillMiningComponent( + private val store: SkillStore, + private val llm: LiteLlm, + private val events: Flow, + 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() + 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 { + 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() +} diff --git a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningParser.kt b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningParser.kt similarity index 99% rename from llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningParser.kt rename to skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningParser.kt index bbc58c5..e9678b3 100644 --- a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningParser.kt +++ b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningParser.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.llm.tools +package pw.binom.agentik.skill.mining import pw.binom.agentik.skills.SkillFile diff --git a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningPrompts.kt b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningPrompts.kt similarity index 99% rename from llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningPrompts.kt rename to skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningPrompts.kt index 6869536..c0d35e2 100644 --- a/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningPrompts.kt +++ b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillMiningPrompts.kt @@ -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 diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillReadTool.kt similarity index 97% rename from standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt rename to skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillReadTool.kt index 553c586..d992202 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt +++ b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillReadTool.kt @@ -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 сам оборачивает её в diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillTools.kt similarity index 83% rename from standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt rename to skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillTools.kt index fcf6273..46436a0 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt +++ b/skill-mining/src/commonMain/kotlin/pw/binom/agentik/skill/mining/SkillTools.kt @@ -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? { - // Минимальный парсер 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) diff --git a/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/FakeLiteLlm.kt b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/FakeLiteLlm.kt new file mode 100644 index 0000000..8053303 --- /dev/null +++ b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/FakeLiteLlm.kt @@ -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>) : 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 = mutableListOf() + + var lastConfig: LiteConversationConfig? = null + var lastContents: List? = null + val conversations = mutableListOf() + + 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 = + 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 = config.initialMessages + private val mutableHistory: MutableList = config.initialMessages.toMutableList() + override val history: List get() = mutableHistory.toList() + override var systemInstruction: String? = config.systemInstruction + override var tools: List = config.tools + + override fun sendStream(prompt: String): Flow = + sendStreamContents(listOf(LiteContentPart.Text(prompt))) + + override fun sendStreamContents(contents: List): Flow { + 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) { + 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): 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() {} +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillMinerTest.kt similarity index 96% rename from standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt rename to skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillMinerTest.kt index 2ebcc38..9b70c98 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt +++ b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillMinerTest.kt @@ -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 { diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillMiningParserTest.kt similarity index 96% rename from standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt rename to skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillMiningParserTest.kt index 8a20474..bccad37 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt +++ b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillMiningParserTest.kt @@ -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 { diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillReadToolTest.kt similarity index 91% rename from standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt rename to skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillReadToolTest.kt index 8e66da7..803f1c1 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt +++ b/skill-mining/src/commonTest/kotlin/pw/binom/agentik/skill/mining/SkillReadToolTest.kt @@ -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) diff --git a/standalone/build.gradle.kts b/standalone/build.gradle.kts index dd59e18..ebe335e 100644 --- a/standalone/build.gradle.kts +++ b/standalone/build.gradle.kts @@ -22,7 +22,7 @@ val skipVectorMemory: Boolean = // :standalone — единственный entry-point проекта, JVM-only по факту (один // `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в // commonMain + commonTest — даже зависимости, которые формально JVM-only -// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут +// (ktor-server-cio, litert-openai, ...). KMP-плагин тут // только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`, // `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет. kotlin { @@ -64,21 +64,36 @@ kotlin { } // SQLite-бэкенд (ksqlite JNI) — JVM-only по факту. - implementation(project(":storage-ksqlite")) + implementation(project(":journal-ksqlite")) + implementation(project(":context-ksqlite")) + implementation(project(":reflection-ksqlite")) + // ksqlite объявлен как `implementation` (не `api`) в каждом + // ksqlite-модуле, поэтому SqliteStores здесь использует + // SQLiteConnection напрямую — фиксируем зависимость явно. + implementation("pw.binom.db:ksqlite:0.1.2") // Bounded-tail live event stream + per-event TTL. implementation(project(":outbox-inmemory")) + // :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider. + // ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.) + // добавляются через agent.install(...). + implementation(project(":agent-api")) + // LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner, // LlmMemoryReviewer, ContextCompactor, парсеры/промпты). implementation(project(":agent-toolsets")) implementation(project(":llm-tools")) + // Skill-подсистема как Component: тулы + SkillMiningComponent. + implementation(project(":skill-mining")) // Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter). implementation(project(":mcp-bridge")) // litert-kmp: контракт (commonMain) api(libs.litert.api) + // liteTool DSL (типизированные LiteTool через @Serializable args) + implementation(libs.litert.tools.kotlinx.serialization) // litert-openai: JVM-реализация implementation(libs.litert.openai) // litert-google: встроенный LiteRT-LM движок, нужен только на runtime diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt index 894a91b..fc0a2a0 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt @@ -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 diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt index 2852c1e..3545267 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -30,9 +30,9 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.ModelDownloader import pw.binom.agentik.mcp.bridge.McpRegistry -import pw.binom.agentik.storage.ksqlite.KsqliteStores +import pw.binom.agentik.standalone.persistence.SqliteStores import java.io.File -import pw.binom.agentik.llm.tools.SkillMiner +import pw.binom.agentik.skill.mining.SkillMiner /** * standalone-контейнер agentik: * - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080) @@ -199,7 +199,7 @@ private fun runServer() { } val llm = config.llm.createLlm() - val sqliteStores = KsqliteStores.open(path = config.agent.dbPath) + val sqliteStores = SqliteStores.open(path = config.agent.dbPath) val mcpRegistry = McpRegistry.fromConfig(config.mcp) // Хранилище скилов: если skillsDir задан, читаем каталог + создаём @@ -214,9 +214,9 @@ private fun runServer() { // Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы, // которые модель забыла сохранить через `skill_save`. Работает только когда // есть куда писать (skillStore) и интервал > 0. - val skillMiner: pw.binom.agentik.llm.tools.SkillMiner? = + val skillMiner: pw.binom.agentik.skill.mining.SkillMiner? = if (skillStore != null && config.skillMining.interval > 0) { - pw.binom.agentik.llm.tools.SkillMiner( + pw.binom.agentik.skill.mining.SkillMiner( llm = llm, maxTurns = config.skillMining.maxTurns, ) @@ -369,7 +369,6 @@ private fun runServer() { reflectionStore = sqliteStores.reflections, llm = llm, llmConfig = config.llm, - tools = mcpRegistry.namedTools, skills = skills, skillStore = skillStore, memoryStore = memorySystem?.store, @@ -382,7 +381,7 @@ private fun runServer() { recentReflections = recentReflections, reflector = reflector, skillMiner = skillMiner, - ) + ).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry)) // Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а, // чтобы debug-эндпоинты могли его триггерить вручную. diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt index 54ae8ca..21a3346 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt @@ -17,7 +17,7 @@ import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.ContextStore import pw.binom.agentik.llm.tools.LlmReflector -import pw.binom.agentik.llm.tools.SkillMiner +import pw.binom.agentik.skill.mining.SkillMiner import java.util.concurrent.atomic.AtomicLong /** diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index cbfae1e..c6c01e2 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -1,12 +1,25 @@ package pw.binom.agentik.standalone.agent import kotlin.time.Instant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.agent.Component +import pw.binom.agentik.agent.ConversationAware +import pw.binom.agentik.agent.ConversationHandle +import pw.binom.agentik.agent.ConversationTurn +import pw.binom.agentik.agent.MutableAgent +import pw.binom.agentik.agent.SystemPromptProvider +import pw.binom.agentik.agent.ToolProvider import pw.binom.agentik.llm.tools.ContextCompactor import pw.binom.agentik.llm.tools.LlmReflector -import pw.binom.agentik.llm.tools.SkillMiner +import pw.binom.agentik.skill.mining.SkillMiner import pw.binom.agentik.memory.MemoryPrefetcher import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemorySystemGuidance @@ -18,8 +31,10 @@ import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Conversation as ProtoConversation import pw.binom.agentik.skills.SkillCatalog -import pw.binom.agentik.skills.renderSystemPromptSection -import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory +import pw.binom.agentik.standalone.agent.memory.MemoryComponent +import pw.binom.agentik.standalone.agent.soul.SoulComponent +import pw.binom.agentik.skill.mining.SkillMiningComponent +import pw.binom.agentik.skill.mining.SkillMiningEvent import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationStore @@ -30,16 +45,17 @@ import pw.binom.agentik.journal.MutableJournalStore import pw.binom.agentik.context.ContextStore import pw.binom.agentik.toolsets.DisableToolsetTool import pw.binom.agentik.toolsets.EnableToolsetTool -import pw.binom.agentik.toolsets.NamedTool +import pw.binom.litert.LiteTool import pw.binom.agentik.toolsets.SystemPromptToolsetSection +import pw.binom.agentik.toolsets.ToolsetComponent import pw.binom.agentik.toolsets.ToolsetContribution import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.agentik.toolsets.ToolsetRegistry import pw.binom.litert.LiteLlm /** - * Stateful [ProtoAgent] на базе SQLite (история + working memory) и - * [LiteLlm] (универсальный LLM-контракт). + * Stateful [MutableAgent] (= [ProtoAgent] + install/uninstall/component extension) + * на базе SQLite (история + working memory) и [LiteLlm] (универсальный LLM-контракт). * * Создаёт [ChatConversation] — те самые stateful диалоги, которые * хранят свой собственный LiteConversation в ОЗУ (для скорости) и @@ -47,6 +63,13 @@ import pw.binom.litert.LiteLlm * * Один [LiteLlm] шарится между всеми беседами агента. * + * **External тулы (MCP, etc.)** приходят не через constructor, а через + * [Component.install]: типичный путь — `McpBridgeComponent` из `:mcp-bridge` + * добавляет [ToolProvider] в [toolProviders] при [install]. На каждом + * `send()` или создании беседы агент вызывает [collectTools] чтобы + * собрать свежий список тулов (= внешние через [toolProviders] + встроенные). + * Это позволяет добавлять/убирать MCP-серверы в runtime без рестарта. + * * Если задан [skills], их каталог (имя + краткое описание) подмешивается в * системный промпт, а в набор тулов добавляется встроенный `read_skill` для * загрузки полного текста навыка по требованию. @@ -57,7 +80,7 @@ import pw.binom.litert.LiteLlm * `ChatConversation` настраивается prefetch (контекст в начале user-сообщения) * и post-turn reviewer (извлечение фактов после каждого хода). */ -class ChatAgent( +internal class ChatAgent( override val id: String, private val mutableConversationStore: MutableConversationStore, private val messageStore: MutableJournalStore, @@ -65,7 +88,6 @@ class ChatAgent( private val reflectionStore: ReflectionStore, private val llm: LiteLlm, private val llmConfig: LlmConfig, - private val tools: List = emptyList(), private val skills: SkillCatalog = SkillCatalog.EMPTY, /** * Хранилище скилов для self-improvement (запись/архивирование). Если @@ -76,6 +98,8 @@ class ChatAgent( private val skillStore: pw.binom.agentik.skills.SkillStore? = null, private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null, private val memoryPrefetcher: MemoryPrefetcher? = null, + internal val _backgroundEvents: BackgroundEventBus = BackgroundEventBus(), + internal val agentScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), private val memoryReviewer: MemoryReviewer? = null, /** * Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало @@ -117,83 +141,230 @@ class ChatAgent( * секция в system prompt НЕ добавляется (полная невидимость per A1-α). */ private val toolsets: List = 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 = mutableListOf() /** - * Реестр активных тулсетов — один на агента (per-agent state). - * `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations. + * Провайдеры секций системного промпта. Заполняются компонентами + * через [install]. На каждом turn'е агент собирает секции через + * [SystemPromptProvider.getSection]. + */ + override val systemProviders: MutableList = mutableListOf() + + /** + * Установленные компоненты. В [close] проходим в обратном порядке + * и дёргаем [Component.uninstall], чтобы каждый успел закрыть свои ресурсы. + */ + private val components: MutableList = mutableListOf() + + /** + * Реестр активных тулсетов — один на агента (per-agent state). Раньше + * жил прямо в ChatAgent, теперь поднимается через [ToolsetComponent] + * (см. init-блок ниже). Доступ к реестру — через [toolsetDispatch]. */ private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets) - /** Список активных тулов (включая enable/disable если есть тулсеты). */ - private val enabledToolsetTools: List = if (toolsets.isNotEmpty()) { - listOf( - NamedTool(EnableToolsetTool.NAME, EnableToolsetTool(toolsetRegistry).tool), - NamedTool(DisableToolsetTool.NAME, DisableToolsetTool(toolsetRegistry).tool), - ) - } else emptyList() - /** * Системный промпт: (soul, если задан) → база → секция навыков → секция памяти - * → секция self-reflection (слабые места) → секция тулсетов (если они заданы). - * Именно он сидируется в working memory и передаётся в [ChatConversation]. + * → секция self-reflection (слабые места). Секция тулсетов теперь приходит + * динамически от [ToolsetComponent] через [systemProviders]. */ - private val systemPrompt: String = buildSystemPrompt( + private val baseSystemPrompt: String = buildSystemPrompt( base = llmConfig.systemPrompt, - skills = skills, memoryEnabled = memoryStore != null, - soulBody = soulBody, reflections = recentReflections, - toolsetSection = if (toolsets.isNotEmpty()) { - SystemPromptToolsetSection.render( - active = emptyList(), // все по умолчанию неактивны - inactive = toolsets, - ) - } else null, ) /** - * Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные: - * `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть - * [skillStore]), `memory_*` (если подключена память), - * `enable_toolset`/`disable_toolset` (если заданы [toolsets]). - * MCP-тулы префиксованы `server__`, так что коллизий нет. + * Тестовый инжектор: позволяет `ChatAgentTest` подсунуть тул, который + * скриптованная LLM будет вызывать. В production-конфигурации всегда пуст — + * инструменты приходят через [toolProviders] (компоненты, [install]). */ - private val allTools: List = buildList { - addAll(tools) - addAll(enabledToolsetTools) - if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills))) - if (skillStore != null) addAll(SkillToolsFactory.create(skillStore)) - if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore)) + private val testTools: MutableList = mutableListOf() + + /** + * Собирает **актуальный** список тулов для диспетчеризации: + * внешние из [toolProviders] + testTools + встроенные (read_skill, + * skill_save, memory_*, toolset enable/disable). + * Вызывается **на каждый tool-call** через [toolsetDispatch] (поэтому без + * кэша — добавление компонента сразу видно в dispatch). + * + * Дедупликация не делается — если два провайдера вернут тул с одинаковым + * именем, второй в lookup'е победит первый. Это намеренно оставлено на + * ответственности компонентов (в типичной конфигурации каждый компонент + * отвечает за свой namespace-префикс, и коллизий нет). + */ + internal fun collectTools(): List = buildList { + toolProviders.forEach { addAll(it.getTools(conversationId = "")) } + addAll(testTools) + // Skill-* тулы приходят через SkillComponent → ToolProvider. + // memory_* тулы приходят через MemoryComponent → ToolProvider. } - private val toolsByName: MutableMap = allTools.associateBy { it.name }.toMutableMap() + /** + * Тестовый хук: регистрирует [tool] под именем [name] в общем пуле. + * Используется только в `ChatAgentTest`. В production-конфигурации тулы + * приходят через [install] компонентов с [ToolProvider]. + */ + internal fun addTestTool(tool: LiteTool) { + testTools.add(tool) + } /** * Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда * [toolsets] пустой (тогда работает как passthrough через baseDispatcher). * Это позволяет ChatConversation.runTurn всегда идти через один путь, * без ветвления «с тулсетами / без». + * + * `baseDispatcher` лениво пересобирает список тулов через [collectTools] + * — это значит добавление/удаление компонента в runtime сразу же видно + * в tool-dispatch следующего turn'а. */ private val toolsetDispatch: ToolsetDispatchPolicy = ToolsetDispatchPolicy( registry = toolsetRegistry, baseDispatcher = { name, args -> - val t = toolsByName[name] + val t = collectTools().firstOrNull { it.name == name } ?: error("unknown tool: $name") - t.tool.invoke(args) + t.invoke(args) }, ) + init { + // Поднимаем механику toolsets как обычный Component — теперь тулсеты + // могут быть добавлены/убраны в runtime через тот же [install] API. + if (toolsets.isNotEmpty()) install(ToolsetComponent(toolsets)) + // Память: тулы + system-provider — отдельным компонентом. + if (memoryStore != null) install(MemoryComponent(memoryStore)) + // Скиллы: тулы + system-provider + фоновый mining. + if (skillStore != null) install( + SkillMiningComponent( + store = skillStore, + llm = llm, + events = skillMiningEvents(), + scope = agentScope, + ) + ) + // Душа: первая секция системного промпта. + if (!soulBody.isNullOrBlank()) install(SoulComponent(soulBody)) + } + + // Системный промпт для беседы — статичная база [baseSystemPrompt] плюс + // динамические секции из [systemProviders] на момент turn'а. + // В ConversationLoop передаётся через [systemPromptResolver] лямбду, + // которая дёргается на каждом `sendStreamContents` — LiteRT v12 setter + // сам делает no-op, если значение эквивалентно. + private val systemPrompt: String = baseSystemPrompt + + /** + * Runtime system prompt = база + динамические секции от `systemProviders`. + * Вызывается на каждом turn'е (через [systemPromptResolver]). Пустые секции + * пропускаются; компонент, у которого `getSection` вернул пустую строку, + * не появляется в промте. + */ + private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow = + merge( + _backgroundEvents.lifecycleEvents.filterIsInstance() + .map { SkillMiningEvent.ConversationClosing(it.conversationId) }, + _backgroundEvents.compactionEvents.filterIsInstance() + .map { SkillMiningEvent.ConversationCompacted(it.conversationId) }, + ) + + internal fun buildRuntimeSystemPrompt(): String { + // Секции от systemProviders идут ПЕРЕД базой — это позволяет компонентам + // типа SoulComponent поставить себя во главу угла. Если нужна секция + // после базы — у компонента должен быть какой-то признак "prepend: false" + // (сейчас все systemProviders считаются prepend-only). + if (systemProviders.isEmpty()) return systemPrompt + val sections = systemProviders.mapNotNull { p -> + val section = p.getSection(conversationId = "") + section.takeIf { it.isNotBlank() } + } + if (sections.isEmpty()) return systemPrompt + return sections.joinToString("\n\n") + "\n\n" + systemPrompt.trimEnd() + } + + /** Прокидывает [component] в [MutableAgent] и запоминает для [close]. */ + override fun install(component: Component): MutableAgent { + component.install(this) + components.add(component) + return this + } + + /** Снимает [component]. Идемпотентно — повторный uninstall безопасен. */ + override fun uninstall(component: Component): MutableAgent { + component.uninstall(this) + components.remove(component) + return this + } + + /** + * Оповещает [ConversationAware] компоненты о новом разговоре. Вызывается + * из [ChatConversation] сразу после конструктора — компонент может + * подписаться на events и запустить фоновые задачи. + */ + override fun attachConversation(handle: ConversationHandle) { + components.filterIsInstance().forEach { it.attachConversation(handle) } + } + + /** Оповещает [ConversationAware] компоненты о закрытии разговора. */ + override fun detachConversation(handle: ConversationHandle) { + components.filterIsInstance().forEach { it.detachConversation(handle) } + } + + /** + * Проекция ChatConversation → ConversationHandle. Даём per-conversation + * компонентам только минимальный view (id / isTemporal / recentTurns), + * чтобы они не лезли в чужие обязанности. + * + * [recentTurns] берёт последние [limit] реплик из working memory — + * это всё, что нужно [SkillMiner] / [LlmReflector] для добычи знаний. + */ + private fun ChatConversation.asHandle(): ConversationHandle = object : ConversationHandle { + override val id: String get() = this@asHandle.id + override val isTemporal: Boolean get() = this@asHandle.isTemporal + override suspend fun recentTurns(limit: Int): List { + 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() + 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() + .joinToString("\n") { it.body } + is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> + entry.content.filterIsInstance() + .joinToString("\n") { it.body } + else -> continue + } + when (entry) { + is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = text + is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> { + pairs += ConversationTurn(userMessage = pendingUser.orEmpty(), assistantMessage = text) + pendingUser = null + } + } + } + pendingUser?.let { pairs += ConversationTurn(userMessage = it, assistantMessage = "") } + return pairs.takeLast(limit) + } + override fun close() = Unit + } + /** * Единый канал всех событий агента — bounded tail с auto-TTL. * @@ -278,7 +449,9 @@ class ChatAgent( eventStore = eventStore, llm = llm, systemPrompt = systemPrompt, - tools = allTools, + systemPromptResolver = { buildRuntimeSystemPrompt() }, + toolsResolver = { collectTools() }, + tools = collectTools(), toolsetDispatch = toolsetDispatch, memoryPrefetcher = memoryPrefetcher, memoryReviewer = memoryReviewer, @@ -293,6 +466,10 @@ class ChatAgent( runBlocking { liveLock.withLock { live[conv.id] = conv } } + // Оповещаем ConversationAware компоненты (например, SkillMiningComponent) + // о новом разговоре — они могут подписаться на events и запустить + // фоновые задачи. + attachConversation(conv.asHandle()) runBlocking { eventStore.append( CommonEvent.Agent( @@ -309,11 +486,13 @@ class ChatAgent( val rec = mutableConversationStore.get(id) ?: return null return newConversation(rec).also { liveLock.withLock { live[id] = it } + attachConversation(it.asHandle()) } } override suspend fun deleteConversation(id: String): Boolean { val conv = liveLock.withLock { live.remove(id) } + conv?.let { detachConversation(it.asHandle()) } conv?.close() val ok = mutableConversationStore.delete(id) if (ok) { @@ -344,7 +523,9 @@ class ChatAgent( eventStore = eventStore, llm = llm, systemPrompt = systemPrompt, - tools = allTools, + systemPromptResolver = { buildRuntimeSystemPrompt() }, + toolsResolver = { collectTools() }, + tools = collectTools(), toolsetDispatch = toolsetDispatch, memoryPrefetcher = memoryPrefetcher, memoryReviewer = memoryReviewer, @@ -361,9 +542,17 @@ class ChatAgent( runBlocking { liveLock.withLock { live.values.forEach { it.close() } + live.values.forEach { detachConversation(it.asHandle()) } live.clear() } } + // Снимаем компоненты в обратном порядке, чтобы каждый успел закрыть свои ресурсы. + // Копия `components` нужна на случай если uninstall модифицирует список. + val snapshot = components.toList() + for (i in snapshot.indices.reversed()) { + runCatching { snapshot[i].uninstall(this) } + } + components.clear() runCatching { llm.close() } } @@ -380,28 +569,17 @@ class ChatAgent( */ internal fun buildSystemPrompt( base: String, - skills: SkillCatalog, memoryEnabled: Boolean, - soulBody: String? = null, reflections: List = 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 } /** diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt index 1518fb1..af60456 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt @@ -34,7 +34,7 @@ internal class CompactionCoordinator( private val log = KotlinLogging.logger {} private val toolsCharsCached: Int by lazy(LazyThreadSafetyMode.PUBLICATION) { - state.tools.sumOf { it.tool.describe().length } + state.tools.sumOf { it.describe().length } } suspend fun compactPreTurnIfNeeded(): Boolean = compactPreTurn(force = false) @@ -147,7 +147,14 @@ internal class CompactionCoordinator( val dropFrom = toCompact.first().orderIdx workingMemory.compact(dropFromOrderIdx = dropFrom, conversationId = state.id, summaryText = summaryText) - state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } } + // После компакта пересборка LiteConversation не нужна: litert ≥13 + // умеет replaceHistory() — синхронизируем движковое состояние с актуальной + // working memory, не теряя LiteRT-LM session. На OpenAI replaceHistory тоже + // лучше (stateless — просто подмена списка). + state.liteConvRef.get()?.let { lc -> + val newHistory = buildHistoryLiteMessages(excludeUserSourceId = null) + runCatching { lc.replaceHistory(newHistory) } + } val after = estimateTokens( systemText = systemText, @@ -189,7 +196,29 @@ internal class CompactionCoordinator( ): LiteConversation { state.liteConvRef.get()?.let { return it } - val pastTurns: List = 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 { + if (state.isTemporal) return emptyList() + return workingMemory.list(state.id) .filter { row -> val isRelevant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant @@ -199,7 +228,7 @@ internal class CompactionCoordinator( } .mapNotNull { row -> val e: WorkingMemoryEntry = row.entry - val msg: LiteMessage? = when (e) { + when (e) { is WorkingMemoryEntry.User -> LiteMessage( LiteRole.USER, applyContextPrefix(e.content.toLiteContents(), e.context), @@ -217,18 +246,7 @@ internal class CompactionCoordinator( ) else -> null } - msg } - - val capped = if (pastTurns.size > MAX_SEEDED_MESSAGES) pastTurns.takeLast(MAX_SEEDED_MESSAGES) else pastTurns - - val config = LiteConversationConfig( - systemInstruction = systemPrompt.takeIf { it.isNotBlank() }, - initialMessages = capped, - tools = state.tools.map { it.tool }, - ) - - return liteLlm.createConversation(config).also { state.liteConvRef.set(it) } } companion object { diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt index 8af2106..b84b5ef 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt @@ -12,9 +12,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import mu.KotlinLogging @@ -47,9 +45,8 @@ import pw.binom.litert.LiteToolCall import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Instant import pw.binom.agentik.llm.tools.LlmReflector -import pw.binom.agentik.llm.tools.SkillMiner +import pw.binom.agentik.skill.mining.SkillMiner import pw.binom.agentik.llm.tools.ContextCompactor -import pw.binom.agentik.toolsets.NamedTool class ConversationLoop( record: ConversationRecord, @@ -59,8 +56,30 @@ class ConversationLoop( private val reflectionStore: ReflectionStore?, private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore, private val llm: LiteLlm, + /** + * Базовый системный промпт, который задаётся беседе при создании. + * В runtime может перетираться через [systemPromptResolver] перед каждым + * `sendStreamContents` (LiteRT v12 mutable setters сами делают no-op, + * если значение эквивалентно). + */ private val systemPrompt: String, - private val tools: List = emptyList(), + /** + * Лямбда, которая возвращает актуальный системный промпт в момент + * каждого `sendStreamContents`. По дефолту — возвращает [systemPrompt] + * без изменений. Production-конфигурация (ChatAgent) подменяет её на + * `{ agent.buildRuntimeSystemPrompt() }`, что добавляет к базе динамические + * секции от установленных компонент (`systemProviders`). + */ + internal var systemPromptResolver: () -> String = { systemPrompt }, + private val tools: List = emptyList(), + /** + * Лямбда, которая возвращает актуальный список тулов в момент каждого + * `sendStreamContents`. По дефолту — фиксированный [tools] снимок. + * Production-конфигурация (ChatAgent) подменяет её на + * `{ collectTools() }` — добавляет/убирает тулы от `toolProviders` + * компонент на лету. + */ + internal var toolsResolver: () -> List = { tools }, private val toolsetDispatch: ToolsetDispatchPolicy? = null, private val memoryPrefetcher: MemoryPrefetcher? = null, private val memoryReviewer: MemoryReviewer? = null, @@ -93,7 +112,7 @@ class ConversationLoop( /** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */ private val backgroundEvents = BackgroundEventBus() - private val toolsByName: MutableMap = tools.associateBy { it.name }.toMutableMap() + private val toolsByName: MutableMap = tools.associateBy { it.name }.toMutableMap() private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher) @@ -118,7 +137,6 @@ class ConversationLoop( toolsByName = toolsByName, toolsetDispatch = toolsetDispatch, newId = ::newId, - encodeArgsJson = ::encodeArgsJson, now = ::now, ) @@ -237,10 +255,6 @@ class ConversationLoop( suspend fun forceCompactNow(): Boolean = compactor.forceCompactNow() - internal fun registerToolForTest(name: String, tool: LiteTool) { - toolDispatcher.registerToolForTest(name, tool) - } - private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) { val wasInterruptedAtEntry = interrupted.get() if (!state.isTemporal) { @@ -275,7 +289,7 @@ class ConversationLoop( val conv = try { compactor.getOrCreateLiteConversation( - systemPrompt = systemPrompt, + systemPrompt = systemPromptResolver(), excludeUserSourceId = if (state.isTemporal) null else userRecord.id, ) } catch (e: Throwable) { @@ -307,6 +321,10 @@ class ConversationLoop( val collectedCalls = mutableListOf() if (pendingParts != null) { val lc = state.liteConvRef.get() ?: return + // Hot-swap перед каждым sendStreamContents — v12 setter no-op + // если значение эквивалентно текущему (см. LiteConversation.systemInstruction). + lc.systemInstruction = systemPromptResolver() + lc.tools = toolsResolver() try { lc.sendStreamContents(pendingParts).collect { delta -> if (delta.text.isNotEmpty()) { @@ -359,6 +377,10 @@ class ConversationLoop( try { val collectedPostTool = mutableListOf() val lc = state.liteConvRef.get() ?: return + // Hot-swap перед follow-up — на случай если компонент + // изменил toolset/soul/skills между turn'ами. + lc.systemInstruction = systemPromptResolver() + lc.tools = toolsResolver() lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp -> if (followUp.text.isNotEmpty()) { reply.append(followUp.text) @@ -492,28 +514,6 @@ class ConversationLoop( private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix) - private fun encodeArgsJson(arguments: Map): String { - 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 } diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt index 7165741..ccc3289 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt @@ -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, + val tools: List, val agentScope: CoroutineScope, ) { @Volatile diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt deleted file mode 100644 index 54d26e6..0000000 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt +++ /dev/null @@ -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 = listOf( - NamedTool("skill_save", SkillSaveTool(store)), - NamedTool("skill_delete", SkillDeleteTool(store)), - ) -} diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt index 8e9fa2c..c92bd72 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt @@ -12,17 +12,15 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.litert.LiteToolCall import pw.binom.litert.LiteTool import kotlin.time.Instant -import pw.binom.agentik.toolsets.NamedTool internal class ToolDispatcher( private val state: ConversationState, private val messageStore: MutableJournalStore, private val events: ConversationEvents, private val backgroundEvents: BackgroundEventBus, - private val toolsByName: MutableMap, + private val toolsByName: MutableMap, private val toolsetDispatch: ToolsetDispatchPolicy?, private val newId: (String) -> String, - private val encodeArgsJson: (Map) -> String, private val now: () -> Instant, ) { private val log = KotlinLogging.logger {} @@ -32,14 +30,11 @@ internal class ToolDispatcher( val currentToolJob: Job? get() = _currentToolJob - internal fun registerToolForTest(name: String, tool: LiteTool) { - toolsByName[name] = NamedTool(name = name, tool = tool) - } - suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange { val callId = newId("tc") val resultId = newId("tr") - val argsJson = encodeArgsJson(call.arguments) + // v10: LiteToolCall.arguments — уже raw JSON-строка (раньше была Map). + val argsJson = call.arguments val nowTs = now() val startMs = System.currentTimeMillis() @@ -65,7 +60,7 @@ internal class ToolDispatcher( log.warn { "tool '${call.name}' requested but not registered" } "[tool not found: ${call.name}]" } else { - t.tool.invoke(argsJson) + t.invoke(argsJson) } } else { val d = toolsetDispatch diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryComponent.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryComponent.kt new file mode 100644 index 0000000..260fcf6 --- /dev/null +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryComponent.kt @@ -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 = + MemoryToolsFactory.create(store) +} + +private class MemorySystemProvider : SystemPromptProvider { + override fun getSection(conversationId: String): String = + MemorySystemGuidance.MEMORY_GUIDANCE +} diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt index 95833c5..29faf48 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt @@ -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): 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): 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 = 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( + 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( + 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( + 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( + 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}"}""" } diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt index fa1203b..e446c97 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt @@ -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 = 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 = 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) } -} diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/soul/SoulComponent.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/soul/SoulComponent.kt new file mode 100644 index 0000000..ad409cc --- /dev/null +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/soul/SoulComponent.kt @@ -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() +} diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt new file mode 100644 index 0000000..2fa5178 --- /dev/null +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/SqliteStores.kt @@ -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), + ) + } +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt index b7f146e..c7db7e9 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt @@ -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") } } diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index 9cf0747..70830fb 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -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 = 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 = 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() @@ -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() override val history: List get() = hist.toList() + override var systemInstruction: String? = config.systemInstruction + override var tools: List = config.tools + override fun replaceHistory(newHistory: List) { + hist.clear() + hist.addAll(newHistory) + } override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt))) override fun sendStreamContents(contents: List): Flow { 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"}""")), ), ) } diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt index 83a7a33..64a376a 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt @@ -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 = emptyList(), - ): Pair { - val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}") + ): Pair { + 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 { diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt index 011b89e..38a666a 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt @@ -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() } diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt index 6267b00..21254c9 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt @@ -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>>) : Reply() + data class ToolCalls(val calls: List>) : Reply() } override val backendName: String = "fake" @@ -67,6 +67,8 @@ internal class FakeLiteConversation( val initialMessages: List = config.initialMessages private val mutableHistory: MutableList = config.initialMessages.toMutableList() override val history: List get() = mutableHistory.toList() + override var systemInstruction: String? = config.systemInstruction + override var tools: List = config.tools override fun sendStream(prompt: String): Flow = sendStreamContents(listOf(LiteContentPart.Text(prompt))) @@ -104,6 +106,11 @@ internal class FakeLiteConversation( } } } + override fun replaceHistory(newHistory: List) { + mutableHistory.clear() + mutableHistory.addAll(newHistory) + } + override fun send(prompt: String): String { parent.lastContents = listOf(LiteContentPart.Text(prompt)) return parent.reply diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/InMemorySkillStore.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/InMemorySkillStore.kt new file mode 100644 index 0000000..3197bed --- /dev/null +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/InMemorySkillStore.kt @@ -0,0 +1,28 @@ +package pw.binom.agentik.standalone.agent + +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillFile +import pw.binom.agentik.skills.SkillStore +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** Минимальный in-memory [SkillStore] для commonTest. */ +class InMemorySkillStore(initial: List = emptyList()) : SkillStore { + private val lock = ReentrantLock() + private val active = LinkedHashMap().apply { + initial.forEach { put(it.name, it) } + } + + override val catalog: SkillCatalog + get() = lock.withLock { SkillCatalog(active.values.toList()) } + + override fun upsert(skill: SkillFile) = lock.withLock { + active[skill.name] = skill + } + + override fun remove(name: String): Boolean = lock.withLock { + active.remove(name) != null + } + + override fun close() = Unit +} diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt index 021c883..6c7516b 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt @@ -30,7 +30,7 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.OpenAiConfig -import pw.binom.agentik.storage.ksqlite.KsqliteStores +import pw.binom.agentik.standalone.persistence.SqliteStores import pw.binom.agentik.llm.tools.ContextCompactor import pw.binom.agentik.llm.tools.SummaryTurn import kotlin.test.AfterTest @@ -49,13 +49,13 @@ import kotlin.time.Instant */ class MemoryWiringTest { - private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores + private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores private lateinit var fakeLlm: FakeLiteLlm private lateinit var root: Path @BeforeTest fun setup() { - sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}") + sqliteStores = SqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}") fakeLlm = FakeLiteLlm() root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}") SystemFileSystem.createDirectories(root, mustCreate = true) @@ -173,7 +173,7 @@ class MemoryWiringTest { assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names) // Каждый tool описывается валидной JSON-схемой: for (t in tools) { - assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}") + assertTrue(t.describe().contains("\"description\""), "describe() for ${t.name}") } system.close() } @@ -182,23 +182,23 @@ class MemoryWiringTest { fun `memory_save tool round-trips a note through the store`() = runBlocking { val system = openMdMemorySystem(root) val tools = MemoryToolsFactory.create(system.store).associateBy { it.name } - val saveResult = tools.getValue("memory_save").tool.invoke( + val saveResult = tools.getValue("memory_save").invoke( """{"category":"preference","content":"prefers tabs over spaces"}""", ) assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult") assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult") - val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""") + val listResult = tools.getValue("memory_list").invoke("""{"limit":10}""") assertTrue(listResult.contains("prefers tabs over spaces"), "list returned: $listResult") - val readResult = tools.getValue("memory_read").tool.invoke( + val readResult = tools.getValue("memory_read").invoke( """{"query":"tabs","top_k":3}""", ) assertTrue(readResult.contains("prefers tabs over spaces"), "read returned: $readResult") - val deleteResult = tools.getValue("memory_delete").tool.invoke( + val deleteResult = tools.getValue("memory_delete").invoke( Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m -> """{"id":"${m.groupValues[1]}"}""" } ?: error("save did not return id"), diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt index d7f21d4..621e9f5 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt @@ -7,6 +7,8 @@ import java.io.File import kotlin.uuid.Uuid import kotlinx.coroutines.runBlocking import pw.binom.agentik.skills.DiskSkillStore +import pw.binom.agentik.skill.mining.SkillSaveTool +import pw.binom.agentik.skill.mining.SkillDeleteTool import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillStore diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt index 8eadffd..f1941de 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt @@ -5,7 +5,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertSame import kotlin.test.assertTrue -import pw.binom.agentik.toolsets.NamedTool import pw.binom.agentik.mcp.bridge.McpConfig import pw.binom.agentik.mcp.bridge.McpRegistry @@ -16,28 +15,26 @@ class McpRegistryTest { val registry = McpRegistry.fromConfig(McpConfig.empty()) assertEquals(0, registry.allTools.size) assertEquals(0, registry.connectedServerCount) - assertEquals(emptyList(), registry.namedTools) registry.close() } @Test fun `empty servers list produces empty registry`() { val registry = McpRegistry(servers = emptyList()) - assertEquals(0, registry.namedTools.size) assertEquals(0, registry.allTools.size) registry.close() } @Test - fun `NamedTool holds name and tool reference`() { + fun `LiteTool name is exposed`() { val noop: LiteTool = object : LiteTool { + override val name: String = "server__echo" override fun describe(): String = "{}" override fun invoke(arguments: String): String = "" } - val nt = NamedTool(name = "server__echo", tool = noop) - assertEquals("server__echo", nt.name) - assertSame(noop, nt.tool) - assertEquals("{}", nt.tool.describe()) + assertEquals("server__echo", noop.name) + assertSame(noop, noop) + assertEquals("{}", noop.describe()) } @Test diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt index c887900..c10e9c9 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt @@ -8,7 +8,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest -import pw.binom.agentik.storage.ksqlite.KsqliteStores +import pw.binom.agentik.standalone.persistence.SqliteStores import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -22,11 +22,11 @@ import kotlin.time.Instant class PersistenceTest { - private lateinit var stores: KsqliteStores + private lateinit var stores: SqliteStores @BeforeTest fun setup() { - stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}") + stores = SqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}") } @AfterTest diff --git a/storage-inmemory/README.md b/storage-inmemory/README.md deleted file mode 100644 index 9d010b6..0000000 --- a/storage-inmemory/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# `:storage-inmemory` — in-memory реализация `:storage-core` (KMP, jvm + native) - -## Что это - -In-memory реализация `MessageStore / WorkingMemoryStore / -ConversationStore / ReflectionStore`. Все структуры держит в -`ConcurrentHashMap` + `MutableList`, фолотится на RAM -(никаких файлов). - -Решает: дешёвая тестовая среда без поднятия SQLite. Позволяет -прогонять `ChatAgentTest` за миллисекунды и держать сценарии -детерминированными. - -## Где используется - -- В тестах `:standalone` (`AbstractITTest`). -- В Android-имплементации (in-memory + Android-database микс). -- В любых юнит-тестах на агенте. - -## Как подключить - -```kotlin -commonMain.dependencies { - api("pw.binom.agentik:storage-inmemory:0.1.0") - api("pw.binom.agentik:storage-core:0.1.0") -} - -val storage = InMemoryStorageSystem() -val messages: MessageStore = storage.messages -val working: WorkingMemoryStore = storage.working -``` - -## Версии - -`gradle/libs.versions.toml` → `[versions] agentik-storage-inmemory`. - -## Тесты - -``` -./gradlew :storage-inmemory:allTests -``` - -Покрывают (через общие contract-tests): round-trip, paged flow, -concurrent appends, working-memory replay, очистку. - -## Чего здесь НЕТ - -- Никакого persistence. Перезапуск процесса — данные пропали. - Это нормально для тестов и Android in-memory. - -## Текущий статус - -Используется продакшеном (в режиме тестов). Контракт-совместима -с `:storage-sqlite` 1:1 — переключение `AGENTIK_STORAGE_BACKEND=memory` -в `:standalone`. diff --git a/storage-inmemory/build.gradle.kts b/storage-inmemory/build.gradle.kts deleted file mode 100644 index 9f0ff4c..0000000 --- a/storage-inmemory/build.gradle.kts +++ /dev/null @@ -1,33 +0,0 @@ -plugins { - alias(libs.plugins.kotlin.multiplatform) -} - -kotlin { - jvmToolchain(21) - - // Зеркалит набор :storage-core — in-memory импл, чтобы тесты и embedded - // (Android) запуски не зависели от SQLite/JDBC. Совпадает по семантике - // с :storage-sqlite (тред-безопасность через Mutex, AutoCloseable). - jvm() - macosX64() - macosArm64() - iosX64() - iosArm64() - iosSimulatorArm64() - linuxX64() - linuxArm64() - mingwX64() - - sourceSets { - commonMain.dependencies { - api(project(":journal-api")) - api(project(":journal-inmemory")) - api(project(":reflection-api")) - api(project(":context-api")) - } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - } - } -} diff --git a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStore.kt b/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStore.kt deleted file mode 100644 index f3f6a30..0000000 --- a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStore.kt +++ /dev/null @@ -1,49 +0,0 @@ -package pw.binom.agentik.storage.inmemory - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import pw.binom.agentik.journal.MessageRecord -import pw.binom.agentik.journal.MutableJournalStore -import kotlin.time.Instant - -/** - * Thread-safe append-only лог сообщений в памяти. - * - * Хранит все записи в одном `List`, индексированном - * `conversationId`. Подходит для unit-тестов и ephemeral runtime (например, - * CLI-сессии или in-memory demo), где не нужен долгоживущий persistence. - * - * В отличие от ksqlite-импла, не требует native SQLite — работает в любом - * KMP-таргете (включая iOS/native). - */ -class InMemoryMessageStore : MutableJournalStore { - - private val byConv: MutableMap> = mutableMapOf() - private val mutex = Mutex() - - override suspend fun append(record: MessageRecord) { - mutex.withLock { - val list = byConv.getOrPut(record.conversationId) { mutableListOf() } - list.add(record) - } - } - - override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List { - mutex.withLock { - val all = byConv[conversationId].orEmpty() - val filtered = all.filter { it.createdAt > after } - .sortedBy { it.createdAt } - return filtered.drop(offset).take(limit) - } - } - - override suspend fun clear(conversationId: String) { - mutex.withLock { - byConv.remove(conversationId) - } - } - - override fun close() { - // no-op - } -} diff --git a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryStorage.kt b/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryStorage.kt deleted file mode 100644 index 439c79d..0000000 --- a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryStorage.kt +++ /dev/null @@ -1,37 +0,0 @@ -package pw.binom.agentik.storage.inmemory - -import pw.binom.agentik.context.ContextStore -import pw.binom.agentik.journal.MutableJournalStore -import pw.binom.agentik.journal.MutableConversationStore -import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore -import pw.binom.agentik.reflection.ReflectionStore -import kotlin.time.Clock - -/** - * Фабрика готового набора in-memory store'ов. - * - * Удобно для: - * - тестов (быстрая инициализация, не нужен JDBC driver); - * - embedded-сценариев (Android ART, edge-узлы); - * - dry-run / preview, где SQLite не нужен. - * - * Семантика полностью совпадает с SQLite-имплами (`:storage-sqlite`). - * - * Возвращает четыре store'а по отдельности — caller пробрасывает их - * туда, где нужны (раньше был `StorageBundle`, от него отказались). - */ -object InMemoryStorage { - data class Bundle( - val conversationStore: MutableConversationStore, - val messageStore: MutableJournalStore, - val workingMemoryStore: ContextStore, - val reflectionStore: ReflectionStore, - ) - - fun create(clock: Clock = Clock.System): Bundle = Bundle( - conversationStore = InMemoryMutableConversationStore(clock), - messageStore = InMemoryMessageStore(), - workingMemoryStore = InMemoryWorkingMemoryStore(), - reflectionStore = InMemoryReflectionStore(), - ) -} diff --git a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStore.kt b/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStore.kt deleted file mode 100644 index eff5b3e..0000000 --- a/storage-inmemory/src/commonMain/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStore.kt +++ /dev/null @@ -1,90 +0,0 @@ -package pw.binom.agentik.storage.inmemory - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import pw.binom.agentik.journal.Ids -import pw.binom.agentik.context.WorkingMemoryEntry -import pw.binom.agentik.context.WorkingMemoryRow -import pw.binom.agentik.context.ContextStore -import kotlin.time.Instant - -/** - * Thread-safe List-импл [WorkingMemoryStore]. - * - * Append дает `order_idx = max(existing.order_idx) + 1` (или 0 если пусто). - * `compact` атомарно удаляет строки в диапазоне `[dropFromOrderIdx, +∞)` - * и опционально вставляет [WorkingMemoryEntry.Summary] в хвост. - * - * Семантика 1:1 с SQLite-имплом (см. `:storage-sqlite` после commit 3). - */ -class InMemoryWorkingMemoryStore : ContextStore { - - private val byConv: MutableMap> = mutableMapOf() - private val mutex = Mutex() - - private suspend fun nextOrderIdx(conversationId: String): Long = mutex.withLock { - (byConv[conversationId]?.maxOfOrNull { it.orderIdx } ?: -1L) + 1L - } - - override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) { - mutex.withLock { - val rows = byConv.getOrPut(conversationId) { mutableListOf() } - val nextIdx = (rows.maxOfOrNull { it.orderIdx } ?: -1L) + 1L - rows.add( - WorkingMemoryRow( - id = Ids.new("wm"), - conversationId = conversationId, - orderIdx = nextIdx, - sourceMessageId = entry.sourceMessageId, - entry = entry, - createdAt = now, - ) - ) - } - } - - override suspend fun list(conversationId: String): List { - mutex.withLock { - return byConv[conversationId].orEmpty() - .sortedBy { it.orderIdx } - .toList() - } - } - - override suspend fun clear(conversationId: String) { - mutex.withLock { - byConv.remove(conversationId) - } - } - - override suspend fun compact( - dropFromOrderIdx: Long, - conversationId: String, - summaryText: String?, - ): Long = mutex.withLock { - val rows = byConv.getOrPut(conversationId) { mutableListOf() } - rows.removeAll { it.orderIdx >= dropFromOrderIdx } - val newMax = rows.maxOfOrNull { it.orderIdx } ?: -1L - return if (summaryText.isNullOrBlank()) { - newMax - } else { - val summary = WorkingMemoryEntry.Summary(text = summaryText) - val newIdx = newMax + 1L - rows.add( - WorkingMemoryRow( - id = Ids.new("wm"), - conversationId = conversationId, - orderIdx = newIdx, - sourceMessageId = null, - entry = summary, - createdAt = kotlin.time.Clock.System.now(), - ) - ) - newIdx - } - } - - override fun close() { - // no-op - } -} diff --git a/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStoreTest.kt b/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStoreTest.kt deleted file mode 100644 index ae5cfe9..0000000 --- a/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryMessageStoreTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package pw.binom.agentik.storage.inmemory - -import pw.binom.agentik.journal.Content -import pw.binom.agentik.journal.MessageRecord -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest - -class InMemoryMessageStoreTest { - - @Test - fun `append and listFlow returns inserted records in createdAt order`() = runTest { - val store = InMemoryMessageStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - val u = MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0) - val a = MessageRecord.AssistantMessage("a1", "c1", listOf(Content.Text("hello")), t0.plus(kotlin.time.Duration.parse("PT1S")), null) - store.append(u) - store.append(a) - val all = store.listFlow("c1", Instant.DISTANT_PAST).toList() - assertEquals(listOf("u1", "a1"), all.map { it.id }) - } - - @Test - fun `list filters by after and supports offset+limit`() = runTest { - val store = InMemoryMessageStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - for (i in 0 until 5) { - store.append( - MessageRecord.UserMessage( - "u$i", - "c1", - listOf(Content.Text("msg-$i")), - t0.plus(kotlin.time.Duration.parse("PT${i}S")), - ) - ) - } - // after=t0+1s должны видеть только msg-2..4 (т.е. u2,u3,u4) - val after = t0.plus(kotlin.time.Duration.parse("PT1S")) - val page = store.list("c1", after = after, offset = 0, limit = 10) - assertEquals(listOf("u2", "u3", "u4"), page.map { it.id }) - - val page2 = store.list("c1", after = after, offset = 1, limit = 10) - assertEquals(listOf("u3", "u4"), page2.map { it.id }) - } - - @Test - fun `listFlow returns empty for unknown conversation`() = runTest { - val store = InMemoryMessageStore() - assertTrue(store.listFlow("none", Instant.DISTANT_PAST).toList().isEmpty()) - } - - @Test - fun `isolates conversations - listFlow returns only requested conv`() = runTest { - val store = InMemoryMessageStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.append(MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0)) - store.append(MessageRecord.UserMessage("u2", "c2", listOf(Content.Text("hello")), t0)) - assertEquals(listOf("u1"), store.listFlow("c1", Instant.DISTANT_PAST).toList().map { it.id }) - assertEquals(listOf("u2"), store.listFlow("c2", Instant.DISTANT_PAST).toList().map { it.id }) - } - - @Test - fun `close is idempotent and does nothing`() { - val store = InMemoryMessageStore() - store.close() - store.close() - } - - @Test - fun `append with null context and null tokens is supported`() = runTest { - val store = InMemoryMessageStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - val msg = MessageRecord.UserMessage( - id = "u1", - conversationId = "c1", - content = listOf(Content.Text("hi")), - createdAt = t0, - context = null, - ) - store.append(msg) - val got = store.listFlow("c1", Instant.DISTANT_PAST).first() as MessageRecord.UserMessage - assertNull(got.context) - } -} diff --git a/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryReflectionStoreTest.kt b/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryReflectionStoreTest.kt deleted file mode 100644 index 5415879..0000000 --- a/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryReflectionStoreTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package pw.binom.agentik.storage.inmemory - -import pw.binom.agentik.reflection.Reflection -import pw.binom.agentik.reflection.ReflectionEvent -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.yield -import kotlinx.coroutines.test.runTest - -class InMemoryReflectionStoreTest { - - private fun sample(id: String, convId: String?, at: Instant, score: Int = 4) = Reflection( - id = id, - conversationId = convId, - createdAt = at, - turnsAnalyzed = 5, - score = score, - summary = "ok", - weakSpots = listOf("weakness-1"), - ) - - @Test - fun `insert and get roundtrip preserves all fields`() = runTest { - val store = InMemoryReflectionStore() - val r = sample("r1", "c1", Instant.parse("2026-09-15T10:00:00Z")) - store.insert(r) - assertEquals(r, store.get("r1")) - } - - @Test - fun `get returns null for missing id`() = runTest { - val store = InMemoryReflectionStore() - assertNull(store.get("nope")) - } - - @Test - fun `listRecent returns most-recent first up to limit`() = runTest { - val store = InMemoryReflectionStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - for (i in 0 until 5) { - store.insert(sample("r$i", "c$i", t0.plus(kotlin.time.Duration.parse("PT${i}S")))) - } - val top3 = store.listRecent(limit = 3) - assertEquals(listOf("r4", "r3", "r2"), top3.map { it.id }) - } - - @Test - fun `listForConversation returns only entries for that conversation most-recent first`() = runTest { - val store = InMemoryReflectionStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.insert(sample("r1", "c1", t0)) - store.insert(sample("r2", "c2", t0.plus(kotlin.time.Duration.parse("PT1S")))) - store.insert(sample("r3", "c1", t0.plus(kotlin.time.Duration.parse("PT2S")))) - store.insert(sample("r4", "c1", t0.plus(kotlin.time.Duration.parse("PT3S")))) - val c1List = store.listForConversation("c1", limit = 10) - assertEquals(listOf("r4", "r3", "r1"), c1List.map { it.id }) - } - - @Test - fun `listForConversation with null conversationId returns global ones`() = runTest { - val store = InMemoryReflectionStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.insert(sample("r1", null, t0)) - store.insert(sample("r2", "c1", t0.plus(kotlin.time.Duration.parse("PT1S")))) - val global = store.listForConversation("", limit = 10) - assertEquals(listOf("r1"), global.map { it.id }) - } - - @Test - fun `deleteOlderThan removes entries created before cutoff`() = runTest { - val store = InMemoryReflectionStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.insert(sample("r-old", "c1", t0)) - store.insert(sample("r-old2", "c1", t0.plus(kotlin.time.Duration.parse("PT10S")))) - store.insert(sample("r-new", "c1", t0.plus(kotlin.time.Duration.parse("PT60S")))) - store.deleteOlderThan(cutoff = t0.plus(kotlin.time.Duration.parse("PT30S"))) - assertEquals(1, store.count()) - assertNotNull(store.get("r-new")) - assertNull(store.get("r-old")) - assertNull(store.get("r-old2")) - } - - @Test - fun `count reflects inserts`() = runTest { - val store = InMemoryReflectionStore() - assertEquals(0, store.count()) - store.insert(sample("r1", "c1", Instant.parse("2026-09-15T10:00:00Z"))) - store.insert(sample("r2", "c1", Instant.parse("2026-09-15T10:01:00Z"))) - assertEquals(2, store.count()) - } - - @Test - fun `events flow emits Created on insert`() = runTest { - val store = InMemoryReflectionStore() - val r = sample("r1", "c1", Instant.parse("2026-09-15T10:00:00Z")) - // SharedFlow не реплеит — запускаем коллектор ДО insert. - val events = store.events() - val deferred = async { events.first() } - yield() // даём коллектору подписаться ДО insert - store.insert(r) - val ev = deferred.await() - assertTrue(ev is ReflectionEvent.Created) - assertEquals("r1", (ev as ReflectionEvent.Created).reflection.id) - } - - @Test - fun `close is idempotent and does nothing`() { - val store = InMemoryReflectionStore() - store.close() - store.close() - } -} diff --git a/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStoreTest.kt b/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStoreTest.kt deleted file mode 100644 index da0a582..0000000 --- a/storage-inmemory/src/commonTest/kotlin/pw/binom/agentik/storage/inmemory/InMemoryWorkingMemoryStoreTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package pw.binom.agentik.storage.inmemory - -import pw.binom.agentik.journal.Content -import pw.binom.agentik.context.WorkingMemoryEntry -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant -import kotlinx.coroutines.test.runTest - -class InMemoryWorkingMemoryStoreTest { - - @Test - fun `append assigns sequential order_idx starting from 0`() = runTest { - val store = InMemoryWorkingMemoryStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S"))) - store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S"))) - val rows = store.list("c1") - assertEquals(2, rows.size) - assertEquals(listOf(0L, 1L), rows.map { it.orderIdx }) - } - - @Test - fun `order_idx continues across conversations independently`() = runTest { - val store = InMemoryWorkingMemoryStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0) - store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0) - // c2 должен начать с 0, не продолжать c1 - val rows2 = store.list("c2") - assertEquals(1, rows2.size) - assertEquals(0L, rows2[0].orderIdx) - } - - @Test - fun `list returns sorted by order_idx ASC`() = runTest { - val store = InMemoryWorkingMemoryStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - // store назначает order_idx = max(existing) + 1, поэтому порядок вставки - // определяет позицию в списке. Сортировка по order_idx даёт ровно - // порядок append'ов. - store.append("c1", WorkingMemoryEntry.User("u0", listOf(Content.Text("a")), null), t0) - store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("b")), null), t0.plus(kotlin.time.Duration.parse("PT1S"))) - store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("c")), null), t0.plus(kotlin.time.Duration.parse("PT2S"))) - val rows = store.list("c1") - assertEquals(listOf("u0", "u1", "u2"), rows.map { it.entry.sourceMessageId }) - // а createdAt — это переданный параметр, не пересчитывается - assertEquals(listOf(t0, t0.plus(kotlin.time.Duration.parse("PT1S")), t0.plus(kotlin.time.Duration.parse("PT2S"))), rows.map { it.createdAt }) - } - - @Test - fun `clear removes all rows for a conversation`() = runTest { - val store = InMemoryWorkingMemoryStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0) - store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0) - store.clear("c1") - assertEquals(emptyList(), store.list("c1")) - assertEquals(1, store.list("c2").size) - } - - @Test - fun `compact without summary drops tail and returns new max`() = runTest { - val store = InMemoryWorkingMemoryStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S"))) - store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S"))) - // dropFromOrderIdx=1 → удаляет всё >= 1 (то есть только Assistant "a1") - val newMax = store.compact(dropFromOrderIdx = 1, conversationId = "c1", summaryText = null) - assertEquals(0L, newMax) - val rows = store.list("c1") - assertEquals(1, rows.size) - assertEquals("u1", rows[0].entry.sourceMessageId) - } - - @Test - fun `compact with summary replaces tail with synthetic Summary row`() = runTest { - val store = InMemoryWorkingMemoryStore() - val t0 = Instant.parse("2026-09-15T10:00:00Z") - store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S"))) - store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S"))) - store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("how are you")), null), t0.plus(kotlin.time.Duration.parse("PT3S"))) - store.append("c1", WorkingMemoryEntry.Assistant("a2", listOf(Content.Text("fine, thanks"))), t0.plus(kotlin.time.Duration.parse("PT4S"))) - // drop tail from idx 2, insert summary - val newMax = store.compact( - dropFromOrderIdx = 2, - conversationId = "c1", - summaryText = "user asked hi and how-are-you, assistant replied", - ) - assertEquals(2L, newMax) - val rows = store.list("c1") - assertEquals(3, rows.size) - val summary = rows.last().entry as WorkingMemoryEntry.Summary - assertEquals("user asked hi and how-are-you, assistant replied", summary.text) - assertNull(rows.last().sourceMessageId) - } - - @Test - fun `close is idempotent and does nothing`() { - val store = InMemoryWorkingMemoryStore() - store.close() - store.close() - } -} diff --git a/storage-ksqlite/build.gradle.kts b/storage-ksqlite/build.gradle.kts deleted file mode 100644 index 1452320..0000000 --- a/storage-ksqlite/build.gradle.kts +++ /dev/null @@ -1,54 +0,0 @@ -plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.kotlin.serialization) -} - -// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite). -// -// Цели сборки: -// - jvm() — основная, тесты гоняются здесь (in-memory DB без файла) -// - linuxX64() / mingwX64() — smoke-проверка что KMP реально KMP -// - androidNative* — НЕ включены, нужны NDK headers иначе gradle падает. -// Когда дойдём до Android-агента — добавим. -// -// Apple targets (macos*, ios*, tvos*, watchos*) невозможно собрать на Linux — -// Kotlin Multiplatform plugin auto-disables их, так что даже не пытаемся. -// -// ksqlite 0.1.2 опубликован в Maven Central (см. settings.gradle.kts — -// mavenCentral() уже в списке репозиториев), так что CI его видит. -// Предыдущая зависимость `pw.binom.db:ksqlite:0.1.1-SNAPSHOT` жила только -// в локальном ~/.m2 и ломала CI. -// text-embedding-kmp с v4 (2026-09-21) опубликован в caffeine Nexus. - -kotlin { - jvmToolchain(21) - - jvm() - linuxX64() - mingwX64() - - sourceSets { - commonMain.dependencies { - // :message-store-api / :working-memory-api / :message-log-api объявлены - // как api-зависимости здесь, в commonMain — без этого commonMain - // не скомпилируется (KsqliteConversationStore, KsqliteMessageStore - // и т.д. используют их типы в commonMain). У них самих есть Apple - // targets (macosX64/Arm64, iosX64/Arm64/SimulatorArm64, linuxArm64), - // так что KMP-метаданные корректно резолвятся для всех таргетов. - - // ksqlite 0.1.2 опубликован в Maven Central — обычный - // `mavenCentral()` в settings.gradle.kts его подтянет. - implementation("pw.binom.db:ksqlite:0.1.2") - implementation(libs.kotlinx.serialization.json) - - api(project(":journal-api")) - api(project(":journal-ksqlite")) - api(project(":reflection-api")) - api(project(":context-api")) - } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - } - } -} diff --git a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteStores.kt b/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteStores.kt deleted file mode 100644 index c8f45c9..0000000 --- a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteStores.kt +++ /dev/null @@ -1,55 +0,0 @@ -package pw.binom.agentik.storage.ksqlite - -import pw.binom.agentik.context.ContextStore -import pw.binom.agentik.journal.MutableJournalStore -import pw.binom.agentik.journal.MutableConversationStore -import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore as JournalKsqliteConversationStore -import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore -import pw.binom.agentik.reflection.ReflectionStore -import pw.binom.db.ksqlite.SQLiteConnection - -/** - * Фабрика 4 store'ов поверх ksqlite. - * - * Lifecycle: открывает [SQLiteConnection] и возвращает bundle из 4 store'ов. - * Каждый store сам прогоняет свою схему в конструкторе - * (`Schema.migrate(connection)` — idempotent `CREATE TABLE IF NOT EXISTS`), так - * что явных вызовов миграции в bundle'е нет. Caller ДОЛЖЕН вызвать [close] - * при завершении. - * - * @param path путь к .db файлу, либо URI для in-memory/shared-cache. - */ -class KsqliteStores internal constructor( - val connection: SQLiteConnection, - val conversations: MutableConversationStore, - val messages: MutableJournalStore, - val workingMemory: ContextStore, - val reflections: ReflectionStore, -) : AutoCloseable { - - override fun close() { - // Закрытие в правильном порядке: зависимые → владелец connection. - conversations.close() - messages.close() - workingMemory.close() - reflections.close() - connection.close() - } - - companion object { - - fun open(path: String): KsqliteStores = - assemble(SQLiteConnection.open(path)) - - fun inMemory(name: String = "agentik-test"): KsqliteStores = - assemble(SQLiteConnection.memory(name)) - - private fun assemble(conn: SQLiteConnection): KsqliteStores = KsqliteStores( - connection = conn, - conversations = JournalKsqliteConversationStore(conn), - messages = KsqliteJournalStore(conn), - workingMemory = KsqliteWorkingMemoryStore(conn), - reflections = KsqliteReflectionStore(conn), - ) - } -} diff --git a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStore.kt b/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStore.kt deleted file mode 100644 index 27aa9f3..0000000 --- a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStore.kt +++ /dev/null @@ -1,225 +0,0 @@ -package pw.binom.agentik.storage.ksqlite - -import kotlinx.serialization.json.Json -import pw.binom.agentik.context.WorkingMemoryEntry -import pw.binom.agentik.context.WorkingMemoryRow -import pw.binom.agentik.context.ContextStore -import pw.binom.agentik.journal.Ids -import pw.binom.db.ksqlite.SQLiteConnection -import pw.binom.db.ksqlite.SQLitePreparedStatement -import kotlin.time.Clock -import kotlin.time.Instant -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext - -/** - * ksqlite-реализация [ContextStore] для `working_memory`. - * - * ## Lifecycle соединения - * - * Семантика владения connection'ом идентична - * `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`: - * - `KsqliteWorkingMemoryStore(connection)` — внешнее соединение, store НЕ - * закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle). - * - `KsqliteWorkingMemoryStore(path)` — открывает файловое соединение, - * прогоняет [Schema.migrate], закрывает соединение в [close]. - * - `KsqliteWorkingMemoryStore.memory(name)` — in-memory, мигрирует, - * закрывает в [close]. - */ -class KsqliteWorkingMemoryStore private constructor( - private val connection: SQLiteConnection, - private val ownsConnection: Boolean, -) : ContextStore { - - constructor(path: String) : this( - connection = SQLiteConnection.open(path = path), - ownsConnection = true, - ) - - constructor(connection: SQLiteConnection) : this( - connection = connection, - ownsConnection = false, - ) - - init { - Schema.migrate(connection) - } - - private val mutex = Mutex() - private val json = Json { ignoreUnknownKeys = true } - - // pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против - // SIGSEGV в StmtHolder.finalize на закрытой connection). - private val insertStmt = connection.prepare( - """ - INSERT INTO ${Schema.TABLE_WORKING_MEMORY} - (${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX}, - ${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND}, - ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}) - VALUES (?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - ) - private val listStmt = connection.prepare( - """ - SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX}, - ${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT} - FROM ${Schema.TABLE_WORKING_MEMORY} - WHERE ${Schema.COL_CONVERSATION_ID} = ? - ORDER BY ${Schema.COL_ORDER_IDX} ASC - """.trimIndent() - ) - private val clearStmt = connection.prepare( - "DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?" - ) - private val maxOrderIdxStmt = connection.prepare( - """ - SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0) - FROM ${Schema.TABLE_WORKING_MEMORY} - WHERE ${Schema.COL_CONVERSATION_ID} = ? - """.trimIndent() - ) - private val dropFromIdxStmt = connection.prepare( - """ - DELETE FROM ${Schema.TABLE_WORKING_MEMORY} - WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ? - """.trimIndent() - ) - private val insertSummaryStmt = connection.prepare( - """ - INSERT INTO ${Schema.TABLE_WORKING_MEMORY} - (${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX}, - ${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND}, - ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}) - VALUES (?, ?, ?, NULL, ?, ?, ?) - """.trimIndent() - ) - - override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) { - mutex.withLock { - val newIdx = maxOrderIdx(conversationId) + 1 - insertStmt.reset() - insertStmt.clearBindings() - insertStmt.bindText(1, Ids.new("wm")) - insertStmt.bindText(2, conversationId) - insertStmt.bindLong(3, newIdx) - val srcId = entry.sourceMessageId - if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4) - insertStmt.bindText(5, entryKind(entry)) - insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry)) - insertStmt.bindLong(7, now.toEpochMilliseconds()) - insertStmt.executeUpdate() - } - } - - override suspend fun list(conversationId: String): List = withContext(Dispatchers.Default) { - mutex.withLock { - listStmt.reset() - listStmt.clearBindings() - listStmt.bindText(1, conversationId) - val out = mutableListOf() - listStmt.executeQuery().use { rs -> - while (rs.next()) { - out.add( - WorkingMemoryRow( - id = rs.getText(0)!!, - conversationId = rs.getText(1)!!, - orderIdx = rs.getLong(2)!!, - sourceMessageId = rs.getText(3), - entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!), - createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!), - ) - ) - } - } - out - } - } - - override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) { - mutex.withLock { - clearStmt.reset() - clearStmt.clearBindings() - clearStmt.bindText(1, conversationId) - clearStmt.executeUpdate() - } - } - - override suspend fun compact( - dropFromOrderIdx: Long, - conversationId: String, - summaryText: String?, - ): Long = withContext(Dispatchers.Default) { - mutex.withLock { - var newMax = 0L - val nowMs = Clock.System.now().toEpochMilliseconds() - val summaryId = Ids.new("wm") - connection.exec("BEGIN") - try { - dropFromIdxStmt.reset() - dropFromIdxStmt.clearBindings() - dropFromIdxStmt.bindText(1, conversationId) - dropFromIdxStmt.bindLong(2, dropFromOrderIdx) - dropFromIdxStmt.executeUpdate() - - if (!summaryText.isNullOrBlank()) { - val afterDelete = maxOrderIdx(conversationId) - val newIdx = afterDelete + 1 - insertSummaryStmt.reset() - insertSummaryStmt.clearBindings() - insertSummaryStmt.bindText(1, summaryId) - insertSummaryStmt.bindText(2, conversationId) - insertSummaryStmt.bindLong(3, newIdx) - insertSummaryStmt.bindText(4, "summary") - insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText))) - insertSummaryStmt.bindLong(6, nowMs) - insertSummaryStmt.executeUpdate() - newMax = newIdx - } else { - newMax = maxOrderIdx(conversationId) - } - connection.exec("COMMIT") - } catch (t: Throwable) { - runCatching { connection.exec("ROLLBACK") } - throw t - } - newMax - } - } - - override fun close() { - insertStmt.close() - listStmt.close() - clearStmt.close() - maxOrderIdxStmt.close() - dropFromIdxStmt.close() - insertSummaryStmt.close() - if (ownsConnection) connection.close() - } - - companion object { - fun memory(name: String? = null): KsqliteWorkingMemoryStore = - KsqliteWorkingMemoryStore( - connection = SQLiteConnection.memory(name), - ownsConnection = true, - ) - } - - private fun maxOrderIdx(conversationId: String): Long { - maxOrderIdxStmt.reset() - maxOrderIdxStmt.clearBindings() - maxOrderIdxStmt.bindText(1, conversationId) - maxOrderIdxStmt.executeQuery().use { rs -> - if (rs.next()) return rs.getLong(0) ?: 0L - } - return 0L - } - - private fun entryKind(e: WorkingMemoryEntry): String = when (e) { - is WorkingMemoryEntry.User -> "user" - is WorkingMemoryEntry.Assistant -> "assistant" - is WorkingMemoryEntry.ToolExchange -> "tool_exchange" - is WorkingMemoryEntry.Summary -> "summary" - } -} diff --git a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/MessageCodecs.kt b/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/MessageCodecs.kt deleted file mode 100644 index d9a2ae4..0000000 --- a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/MessageCodecs.kt +++ /dev/null @@ -1,89 +0,0 @@ -package pw.binom.agentik.storage.ksqlite - -import kotlinx.serialization.json.Json -import pw.binom.agentik.journal.MessageRecord -import pw.binom.agentik.journal.decodeBodyPayload -import pw.binom.agentik.journal.encodeBodyPayload -import pw.binom.db.ksqlite.SQLiteResultSet -import kotlin.time.Instant - -/** - * Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite. - * - * Копия [pw.binom.agentik.storage.sqlite.SqliteMessageStore] — `private` helpers - * нельзя переиспользовать между модулями, поэтому в каждом backend свой набор. - * Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются - * через эти data class'ы (CallPayload/ResultPayload/ErrorPayload). - */ -internal fun encodeRecord(record: MessageRecord): Pair = when (record) { - is MessageRecord.UserMessage -> "user" to encodeBodyPayload( - content = record.content, - context = record.context, - ) - is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload( - content = record.content, - tokens = record.tokens, - ) - is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString( - CallPayload.serializer(), - CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson), - ) - is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString( - ResultPayload.serializer(), - ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result), - ) - is MessageRecord.Error -> "error" to Json.encodeToString( - ErrorPayload.serializer(), - ErrorPayload(message = record.message, code = record.code), - ) -} - -internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord { - val id = getText(0)!! - val convId = getText(1)!! - val kind = getText(2)!! - val payload = getText(3)!! - val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!) - return when (kind) { - "user" -> { - val d = decodeBodyPayload(payload) - MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context) - } - "assistant" -> { - val d = decodeBodyPayload(payload) - MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens) - } - "tool_call" -> { - val p = Json.decodeFromString(CallPayload.serializer(), payload) - MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt) - } - "tool_result" -> { - val p = Json.decodeFromString(ResultPayload.serializer(), payload) - MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt) - } - "error" -> { - val p = Json.decodeFromString(ErrorPayload.serializer(), payload) - MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt) - } - else -> error("Unknown message kind in audit log: $kind") - } -} - -@kotlinx.serialization.Serializable -internal data class CallPayload(val name: String, val title: String?, val argsJson: String) - -/** - * Тулрезалт-сериализация для SQLite. [toolName] денормализован из - * соответствующего `ToolCall.name` для упрощения UI (нет нужды в - * локальной `Map`). Nullable с дефолтом — старые записи - * без поля десериализуются как `null`. - */ -@kotlinx.serialization.Serializable -internal data class ResultPayload( - val toolCallId: String, - val toolName: String? = null, - val result: String?, -) - -@kotlinx.serialization.Serializable -internal data class ErrorPayload(val message: String, val code: String?) diff --git a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/Schema.kt b/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/Schema.kt deleted file mode 100644 index 08b537e..0000000 --- a/storage-ksqlite/src/commonMain/kotlin/pw/binom/agentik/storage/ksqlite/Schema.kt +++ /dev/null @@ -1,116 +0,0 @@ -package pw.binom.agentik.storage.ksqlite - -import pw.binom.db.ksqlite.SQLiteConnection - -/** - * Имена таблиц/колонок/индексов для ksqlite-бэкенда agentik'а, - * которыми владеет `:storage-ksqlite`: `working_memory` + `reflection`. - * - * `conversation` + `message` уехали в `:journal-ksqlite` — - * см. `pw.binom.agentik.journal.ksqlite.Schema`. Управляющий - * [KsqliteStores.open] / [KsqliteStores.inMemory] прогоняет ОБА - * `Schema.migrate(conn)` подряд. - * - * Все DDL/DML в этом модуле ссылаются на эти константы — никаких - * хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е. - */ -internal object Schema { - - /** Версия схемы. Увеличивать при ЛЮБОМ изменении DDL. */ - const val CURRENT_VERSION: Int = 1 - - // ───── Таблицы ───── - const val TABLE_WORKING_MEMORY = "working_memory" - const val TABLE_REFLECTION = "reflection" - - // ───── Колонки working_memory ───── - const val COL_ID = "id" - const val COL_CONVERSATION_ID = "conversation_id" - const val COL_ORDER_IDX = "order_idx" - const val COL_SOURCE_MESSAGE_ID = "source_message_id" - const val COL_KIND = "kind" - const val COL_PAYLOAD_JSON = "payload_json" - const val COL_CREATED_AT = "created_at" - - // ───── Колонки reflection ───── - const val COL_TURNS_ANALYZED = "turns_analyzed" - const val COL_SCORE = "score" - const val COL_SUMMARY = "summary" - const val COL_WEAK_SPOTS_JSON = "weak_spots_json" - - // ───── Индексы ───── - const val IDX_WM_UNIQUE = "idx_wm_unique" - const val IDX_WM_CONV = "idx_wm_conv" - const val IDX_REFLECTION_CREATED = "idx_reflection_created" - const val IDX_REFLECTION_CONV = "idx_reflection_conv" - - /** - * Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД. - * - * Гарантии: - * - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на - * уже-мигрированной БД; - * - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди → - * ROLLBACK оставит БД консистентной. - * - **NOTE**: в сплит-мире user_version как gate перестал работать - * (соседний `:journal-ksqlite` тоже ставит user_version=1, и второй - * вызов short-circuit'ит). Поэтому migrate() просто прогоняет DDL - * idempotently; координация multi-module миграций — ответственность - * вызывающего (см. [KsqliteStores.open] / [KsqliteStores.inMemory]). - */ - fun migrate(conn: SQLiteConnection) { - conn.exec("BEGIN") - try { - conn.exec(v1WorkingMemoryDdl) - conn.exec(v1ReflectionDdl) - conn.exec(v1IndexesDdl) - conn.exec("COMMIT") - } catch (t: Throwable) { - runCatching { conn.exec("ROLLBACK") } - throw t - } - } - - // ───── DDL миграций ───── - // v1 — начальная схема модуля. Пара `CREATE IF NOT EXISTS` → - // миграция idempotent без всяких version-checks. - - private val v1WorkingMemoryDdl = """ - CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY ( - $COL_ID TEXT NOT NULL PRIMARY KEY, - $COL_CONVERSATION_ID TEXT NOT NULL, - $COL_ORDER_IDX INTEGER NOT NULL, - $COL_SOURCE_MESSAGE_ID TEXT, - $COL_KIND TEXT NOT NULL, - $COL_PAYLOAD_JSON TEXT NOT NULL, - $COL_CREATED_AT INTEGER NOT NULL - ); - """ - - private val v1ReflectionDdl = """ - CREATE TABLE IF NOT EXISTS $TABLE_REFLECTION ( - $COL_ID TEXT NOT NULL PRIMARY KEY, - $COL_CONVERSATION_ID TEXT, - $COL_CREATED_AT INTEGER NOT NULL, - $COL_TURNS_ANALYZED INTEGER NOT NULL, - $COL_SCORE INTEGER NOT NULL, - $COL_SUMMARY TEXT NOT NULL, - $COL_WEAK_SPOTS_JSON TEXT NOT NULL DEFAULT '[]' - ); - """ - - private val v1IndexesDdl = """ - -- Working memory: гарантия уникального order_idx внутри conv'а - -- (порядок имеет значение — compaction полагается на монотонность). - CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE - ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX); - CREATE INDEX IF NOT EXISTS $IDX_WM_CONV - ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX); - - CREATE INDEX IF NOT EXISTS $IDX_REFLECTION_CREATED - ON $TABLE_REFLECTION($COL_CREATED_AT DESC); - CREATE INDEX IF NOT EXISTS $IDX_REFLECTION_CONV - ON $TABLE_REFLECTION($COL_CONVERSATION_ID, $COL_CREATED_AT DESC); - """ -} diff --git a/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStoreTest.kt b/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStoreTest.kt deleted file mode 100644 index 0ed845d..0000000 --- a/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/KsqliteWorkingMemoryStoreTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package pw.binom.agentik.storage.ksqlite - -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.journal.Content -import pw.binom.agentik.context.WorkingMemoryEntry -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Instant - -class KsqliteWorkingMemoryStoreTest { - private lateinit var stores: KsqliteStores - - @BeforeTest - fun setup() { - stores = KsqliteStores.inMemory("wm-${kotlin.random.Random.nextLong()}") - } - - @AfterTest - fun tearDown() = stores.close() - - private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User = - WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content))) - - private fun asstMsg(content: String): WorkingMemoryEntry.Assistant = - WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content))) - - @Test - fun testAppendAndListReturnsInOrder() = runTest { - val t = Instant.parse("2026-09-15T10:00:00Z") - stores.workingMemory.append("c1", userMsg("first"), t) - stores.workingMemory.append("c1", asstMsg("reply"), t) - val list = stores.workingMemory.list("c1") - assertEquals(2, list.size) - assertEquals(1L, list[0].orderIdx) - assertEquals(2L, list[1].orderIdx) - } - - @Test - fun testListIsolatesConversations() = runTest { - val t = Instant.parse("2026-09-15T10:00:00Z") - stores.workingMemory.append("c1", userMsg("c1-msg"), t) - stores.workingMemory.append("c2", userMsg("c2-msg"), t) - assertEquals(1, stores.workingMemory.list("c1").size) - assertEquals(1, stores.workingMemory.list("c2").size) - } - - @Test - fun testClearRemovesAllForConversation() = runTest { - val t = Instant.parse("2026-09-15T10:00:00Z") - stores.workingMemory.append("c1", userMsg("a"), t) - stores.workingMemory.append("c1", userMsg("b"), t) - stores.workingMemory.clear("c1") - assertEquals(emptyList(), stores.workingMemory.list("c1")) - } - - @Test - fun testCompactDeletesAndInsertsSummary() = runTest { - val t = Instant.parse("2026-09-15T10:00:00Z") - stores.workingMemory.append("c1", userMsg("a"), t) - stores.workingMemory.append("c1", userMsg("b"), t) - stores.workingMemory.append("c1", userMsg("c"), t) - // dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a). - // Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2. - val newMax = stores.workingMemory.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary") - assertEquals(2L, newMax) - val remaining = stores.workingMemory.list("c1") - assertEquals(2, remaining.size) - assertEquals(1L, remaining[0].orderIdx) - assertEquals(2L, remaining[1].orderIdx) - assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary) - } - - @Test - fun testCompactWithoutSummaryKeepsTailBelow() = runTest { - val t = Instant.parse("2026-09-15T10:00:00Z") - stores.workingMemory.append("c1", userMsg("a"), t) - // dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1. - val newMax = stores.workingMemory.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null) - assertEquals(1L, newMax) - val remaining = stores.workingMemory.list("c1") - assertEquals(1, remaining.size) - assertEquals(1L, remaining[0].orderIdx) - } -} diff --git a/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/SchemaMigrationTest.kt b/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/SchemaMigrationTest.kt deleted file mode 100644 index adc4dc4..0000000 --- a/storage-ksqlite/src/commonTest/kotlin/pw/binom/agentik/storage/ksqlite/SchemaMigrationTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package pw.binom.agentik.storage.ksqlite - -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.context.WorkingMemoryEntry -import pw.binom.agentik.journal.Content -import pw.binom.agentik.journal.ksqlite.Schema as JournalSchema -import pw.binom.db.ksqlite.SQLiteConnection -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * Тесты на Schema.migrate() в `:storage-ksqlite`: - * - fresh DB → создаются `working_memory` + `reflection` + индексы; - * - уже мигрированная БД → migrate() идемпотентен (no-op); - * - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores), - * migrate() приводит её в боевое состояние. - * - * `conversation` + `message` тестируются в - * `pw.binom.agentik.journal.ksqlite.SchemaMigrationTest`. - */ -class SchemaMigrationTest { - - @Test - fun `fresh DB gets working_memory and reflection tables and indexes`() = runTest { - val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}") - try { - // Поднимаем ОБА schema — то же делает KsqliteStores.open(). - JournalSchema.migrate(conn) - Schema.migrate(conn) - - for (table in listOf( - Schema.TABLE_WORKING_MEMORY, - Schema.TABLE_REFLECTION, - )) { - assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()") - } - - for (index in listOf( - 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()") - } - } finally { - conn.close() - } - } - - @Test - fun `migrate is idempotent on already-migrated DB`() = runTest { - val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}") - try { - JournalSchema.migrate(conn) - Schema.migrate(conn) - - // повторный вызов не должен ни упасть, ни пересоздать таблицы - // (CREATE IF NOT EXISTS — no-op) - Schema.migrate(conn) - Schema.migrate(conn) - assertTrue(tableExists(conn, Schema.TABLE_WORKING_MEMORY)) - assertTrue(tableExists(conn, Schema.TABLE_REFLECTION)) - } finally { - conn.close() - } - } - - @Test - fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest { - val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}") - JournalSchema.migrate(conn) - Schema.migrate(conn) - - val stores = KsqliteStores( - connection = conn, - conversations = pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore(conn), - messages = pw.binom.agentik.journal.ksqlite.KsqliteJournalStore(conn), - workingMemory = KsqliteWorkingMemoryStore(conn), - reflections = KsqliteReflectionStore(conn), - ) - try { - // bundle работает end-to-end — working memory append. Никаких - // "no such table" или подобного. - stores.workingMemory.append( - conversationId = "c1", - entry = WorkingMemoryEntry.Summary(text = "init"), - now = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), - ) - assertEquals(1, stores.workingMemory.list("c1").size) - } finally { - stores.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() } - } - } -} diff --git a/vector-index-api/README.md b/vector-index-api/README.md new file mode 100644 index 0000000..a40a4d8 --- /dev/null +++ b/vector-index-api/README.md @@ -0,0 +1,74 @@ +# `:vector-index-api` — общий интерфейс ANN-индекса + +## Что это + +Pure-API модуль: интерфейсы и data-классы, общие для любых бэкендов +ANN-поиска (approximate nearest neighbor). Никаких реализаций и +платформенных зависимостей — только контракт. + +Используется как стабильный API-фасад между кодом, который хочет +искать по embeddings, и реализациями (JVector, ksqlite, sqlite-vec, +inmemory, ...). При замене бэкенда вызывающий код не меняется. + +## Где используется + +Реализации: + +- `:vector-index-jvector` — JVM-only ANN поверх JVector (Datadog). + RAM-only. Самый быстрый для больших датасетов. +- `:vector-index-ksqlite` — KMP brute-force cosine в SQLite. Подходит + для ≤10K записей, имеет persistence "бесплатно". + +## Что внутри + +```kotlin +interface VectorIndexStore : AutoCloseable { + val dimension: Int + suspend fun getSize(): Long + suspend fun search(embedding: FloatArray, limit: Int): List +} + +interface MutableVectorIndexStore : VectorIndexStore { + suspend fun add(id: String?, embedding: FloatArray, payload: String?): VectorIndex + suspend fun delete(id: String): Boolean + suspend fun clear(): Long +} + +class VectorIndex(val id: String, val embedding: FloatArray, val payload: String?) +// equals/hashCode/toString переопределены руками (FloatArray не работает +// корректно в data class). + +data class VectorSearchResult(val index: VectorIndex, val score: Float) + +class VectorIndexAlreadyExistsException : Exception() +``` + +**Конвенции:** +- `id: String?` в `add()` — если `null`, реализация генерирует сама + (формат на усмотрении реализации). +- `payload: String?` — opaque строка, реализация хранит как есть и не + интерпретирует. +- `dimension` фиксируется на уровне индекса и проверяется при `add()` / + `search()`. +- Все мутации и чтения — `suspend` для совместимости с нативной + блокировкой в JNI/JVector и I/O в ksqlite. +- `search()` возвращает топ-K, отсортированный по убыванию score. +- `close()` обязателен (наследуется от `AutoCloseable`). + +## Цели сборки + +KMP (все 9): jvm + linuxX64/Arm64 + macosX64/Arm64 + iosX64/Arm64/ +SimulatorArm64 + mingwX64. Pure-Kotlin stdlib + `kotlinx-coroutines-core`, +никаких нативных зависимостей — поэтому собирается везде. + +## Текущий статус + +API стабилизирован. Реализации покрывают JVM и KMP-without-Apple. + +## Чего здесь НЕТ + +- Никакой сериализации (`@Serializable`) — типы транспортируются + через интерфейсы, не через JSON. +- Никаких auto-generated id правил — реализации решают сами. +- Никаких фильтров на `search()` — чистый ANN без predicate push-down + (если нужен фильтр, делать на стороне вызывающего кода после `search`). diff --git a/vector-index-jvector/README.md b/vector-index-jvector/README.md new file mode 100644 index 0000000..c59da04 --- /dev/null +++ b/vector-index-jvector/README.md @@ -0,0 +1,98 @@ +# `:vector-index-jvector` — ANN-индекс поверх JVector (Datadog) + +## Что это + +JVM-only реализация `MutableVectorIndexStore` поверх +[JVector](https://github.com/jbellis/jvector) — ANN-библиотеки Datadog +(HNSW-подобный граф). Самый быстрый бэкенд в проекте для больших +датасетов (10K+ записей). + +Используется как замена brute-force SQLite-индекса, когда размер +датасета перерастает ~10K записей или важна latency на search. + +## Где используется + +Standalone-агентом в JVM-режиме, когда нужно эффективное семантическое +поисковое хранилище. Прямо сейчас модуль **не подключён** в +`:standalone` — это API-готовый компонент, ждущий интеграции. + +## Что внутри + +```kotlin +class JVectorVectorIndexStore( + override val dimension: Int, + seedEntries: List = emptyList(), +) : MutableVectorIndexStore { + + data class SeedEntry(val id: String, val embedding: FloatArray, val payload: String?) + + override suspend fun getSize(): Long + override suspend fun add(id: String?, embedding: FloatArray, payload: String?): VectorIndex + override suspend fun delete(id: String): Boolean + override suspend fun clear(): Long + override suspend fun search(embedding: FloatArray, limit: Int): List + override fun close() +} +``` + +**Параметры графа** (как в `:memory-vector/JVectorMemoryIndex`): +`M=16, efConstruction=100, neighborOverflow=1.2f, alpha=1.2f`. + +**Thread-safety:** `ReentrantReadWriteLock` — параллельные `search()` ок, +`add`/`delete`/`clear` — эксклюзивно. + +**`delete()`:** убирает id из `idToOrdinal` + помечает ordinal как +удалённый в `BitSet`. Это позволяет повторный `add(id)` после `delete` +(без этого упал бы с `VectorIndexAlreadyExistsException`). + +## Цели сборки + +Только `jvm()` — JVector публикуется только как JVM-библиотека +(нет KMP-таргетов). Для Apple-целей использовать `:vector-index-ksqlite`. + +## Persist + +**НЕ поддерживается** в v1. JVector'у для записи на диск нужна Feature +с `INLINE_VECTORS`, которая в JVector 3.x конфигурируется отдельно и +нестабильна. Альтернатива — пара `(JVectorVectorIndexStore, KVectorStore)` +с persistence embeddings в SQLite и graph-rebuild на старте. Если +persist нужен — это будущая работа. + +## Как подключить + +```kotlin +dependencies { + implementation("pw.binom.agentik:vector-index-jvector:0.1.0") + implementation("pw.binom.agentik:vector-index-api:0.1.0") +} + +val store = JVectorVectorIndexStore(dimension = 768) +store.add(id = "doc-1", embedding = embeddingOf("hello"), payload = """{"src":"chat"}""") +val top = store.search(embedding = queryEmbedding, limit = 10) +``` + +## Тесты + +``` +./gradlew :vector-index-jvector:jvmTest +``` + +Покрывают: explicit/auto id, duplicate throws, delete+idempotency, +clear, top-K sort, payload round-trip, limit > size. + +## Зависимости + +- `io.github.jbellis:jvector:3.0.6` +- `pw.binom.agentik:vector-index-api` +- `kotlinx-coroutines-core` + +## Чего здесь НЕТ + +- Persist (см. выше). +- Фильтрации в `search()` — чистый ANN. +- Multi-target / native — только JVM. + +## Текущий статус + +Реализация готова, тесты зелёные. Интеграция в `:standalone` +отложена до момента, когда понадобится. diff --git a/vector-index-ksqlite/README.md b/vector-index-ksqlite/README.md new file mode 100644 index 0000000..818e2ae --- /dev/null +++ b/vector-index-ksqlite/README.md @@ -0,0 +1,112 @@ +# `:vector-index-ksqlite` — ANN-индекс поверх SQLite (brute-force) + +## Что это + +KMP-реализация `MutableVectorIndexStore` поверх +[ksqlite](https://github.com/caffeine-mgn/ksqlite). Persistence +"бесплатно" — обычная SQLite БД. + +Стратегия поиска: **brute-force cosine similarity**. Один `SELECT` +всех записей → декодирование BLOB → cosine sim в Kotlin → sort desc → +топ-K. Просто, KMP-совместимо, **без** sqlite-vec extension. + +Используется как persistent ANN-индекс для small-to-medium датасетов +(≤10K записей на embedding ~512d). Для больших — переезжать на +`:vector-index-jvector` или sqlite-vec. + +## Где используется + +Standalone-агентом, когда нужна persistence embeddings и масштаб не +превышает ~10K записей. Прямо сейчас модуль **не подключён** в +`:standalone` — это API-готовый компонент, ждущий интеграции. + +## Что внутри + +```kotlin +class KsqliteVectorIndexStore private constructor( + override val dimension: Int, + connection: SQLiteConnection, + ownsConnection: Boolean, +) : MutableVectorIndexStore { + + constructor(dimension: Int, path: String) + constructor(dimension: Int, connection: SQLiteConnection) + + override suspend fun getSize(): Long + override suspend fun add(id: String?, embedding: FloatArray, payload: String?): VectorIndex + override suspend fun delete(id: String): Boolean + override suspend fun clear(): Long + override suspend fun search(embedding: FloatArray, limit: Int): List + override fun close() + + companion object { + fun memory(dimension: Int, name: String? = null): KsqliteVectorIndexStore + } +} +``` + +**Lifecycle:** +- `(dimension, path)` — открывает файловое соединение, мигрирует, + закрывает в `close()`. +- `(dimension, connection)` — внешнее соединение (shared bundle); + store НЕ закрывает его. +- `memory(dimension, name)` — in-memory, мигрирует, закрывает. + +**Schema:** единственная таблица `vector_index (id TEXT PRIMARY KEY, +dimension INTEGER, embedding BLOB, payload TEXT)`. Embedding хранится +как raw float32 little-endian через ручной `FloatArray.toLittleEndianBytes()` / +`ByteArray.toFloatArray()` (hand-rolled в commonMain — без +`java.nio.ByteBuffer`). + +**Миграции:** `PRAGMA user_version` + `BEGIN`/`COMMIT`. Идемпотентен. + +**UUID:** `kotlin.uuid.Uuid.random()` (commonMain-safe, не JVM-only +`java.util.UUID`). + +## Цели сборки + +**Только:** `jvm()`, `linuxX64()`, `linuxArm64()`, `mingwX64()` — +ksqlite 0.1.2 публикует нативные бинари только для этих 4 целей +(плюс 4 Android NDK). Нет macOS/iOS — для Apple использовать +`:vector-index-jvector`. + +## Как подключить + +```kotlin +dependencies { + implementation("pw.binom.agentik:vector-index-ksqlite:0.1.0") + implementation("pw.binom.agentik:vector-index-api:0.1.0") +} + +val store = KsqliteVectorIndexStore(dimension = 768, path = "~/.agentik/vectors.db") +store.add(id = "doc-1", embedding = embeddingOf("hello"), payload = """{"src":"chat"}""") +val top = store.search(embedding = queryEmbedding, limit = 10) +store.close() +``` + +## Тесты + +``` +./gradlew :vector-index-ksqlite:jvmTest +``` + +Покрывают: explicit/auto id, duplicate throws, delete+idempotency, +clear, top-K sort, payload round-trip, BLOB bit-exact round-trip +(FloatArray<->BLOB encoding), schema migration idempotency. + +## Зависимости + +- `pw.binom.db:ksqlite:0.1.2` +- `pw.binom.agentik:vector-index-api` +- `kotlinx-coroutines-core` + +## Чего здесь НЕТ + +- ANN на стороне SQL — нет sqlite-vec. Brute-force в Kotlin. +- Фильтрации в `search()` — чистый ANN. +- Apple targets (см. выше). + +## Текущий статус + +Реализация готова, тесты зелёные. Интеграция в `:standalone` +отложена до момента, когда понадобится.