Compare commits
6 Commits
639c7d1748
..
14
| Author | SHA1 | Date | |
|---|---|---|---|
| a81d92f489 | |||
| 3e583ac8ea | |||
| f878d1c79b | |||
| 266ec38c1b | |||
| e447525059 | |||
| 84f5fd84f3 |
@@ -18,8 +18,9 @@ agentik/
|
|||||||
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
||||||
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
||||||
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
||||||
├── storage-inmemory/ in-memory реализация для тестов и Android
|
│ (исторический, см. journal-api / context-api / reflection-api ниже)
|
||||||
├── storage-sqlite/ SQLite реализация для production
|
├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
|
||||||
|
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
|
||||||
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||||
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||||
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||||
@@ -89,9 +90,9 @@ curl http://localhost:8080/health
|
|||||||
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||||
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||||
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||||
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
- [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
|
||||||
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
- ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
|
||||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
- ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
|
||||||
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||||
|
|
||||||
## Где смотреть версии
|
## Где смотреть версии
|
||||||
@@ -124,7 +125,7 @@ SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппирован
|
|||||||
|
|
||||||
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
- `.gitea/ci.yml` — PR-build, прогон тестов, проверка
|
||||||
shadowjar'ов.
|
shadowjar'ов.
|
||||||
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||||
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||||
|
|||||||
-37
@@ -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.
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// :proto — read-only Agent interface, который MutableAgent расширяет.
|
||||||
|
// Через api(), иначе downstream-impl ChatAgent не сможет
|
||||||
|
// override suspend-методы Agent.
|
||||||
|
api(project(":proto"))
|
||||||
|
// :memory-api — typealias ConversationTurn на memory-api одноимённый
|
||||||
|
// класс, иначе пер-конво компоненты (skill mining, reflection) не
|
||||||
|
// смогут передать его в SkillMiner.mine() напрямую.
|
||||||
|
api(project(":memory-api"))
|
||||||
|
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
|
||||||
|
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
|
||||||
|
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
|
||||||
|
api(libs.litert.api)
|
||||||
|
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
|
||||||
|
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
|
||||||
|
// listener) — оставлен как api, чтобы downstream не забывал объявить.
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нашлёпка поверх [MutableAgent].
|
||||||
|
*
|
||||||
|
* Компонент сам регистрирует в агенте свои capability-провайдеры
|
||||||
|
* при [install] и снимает их при [uninstall]. Агент не знает заранее
|
||||||
|
* ни о структуре компонента, ни о его провайдерах — это просто
|
||||||
|
* хук для свободной композиции.
|
||||||
|
*
|
||||||
|
* Ktor-style API:
|
||||||
|
* ```
|
||||||
|
* val agent = ChatAgent(...)
|
||||||
|
* .install(SkillComponent(store, miner))
|
||||||
|
* .install(ReflectionComponent(reflectionStore, reflector))
|
||||||
|
* .install(MemoryComponent(memorySystem))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Контракт:
|
||||||
|
* - [install] **синхронен**: компонент добавляет свои провайдеры в
|
||||||
|
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
|
||||||
|
* фоновые корутины — компонент запускает их через свой собственный
|
||||||
|
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
|
||||||
|
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
|
||||||
|
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
|
||||||
|
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
|
||||||
|
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
|
||||||
|
*/
|
||||||
|
interface Component {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается агентом при [MutableAgent.install].
|
||||||
|
*
|
||||||
|
* Типичные действия: добавить [SystemPromptProvider] в
|
||||||
|
* `agent.systemProviders`, добавить [ToolProvider] в
|
||||||
|
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
|
||||||
|
*/
|
||||||
|
fun install(agent: MutableAgent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается агентом при [MutableAgent.uninstall] или при
|
||||||
|
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
|
||||||
|
* которые добавил в [install], и остановить фоновые джобы.
|
||||||
|
*/
|
||||||
|
fun uninstall(agent: MutableAgent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
|
||||||
|
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
|
||||||
|
*
|
||||||
|
* [MutableAgent] при создании/закрытии разговора вызывает
|
||||||
|
* [attachConversation] / [detachConversation] на каждом компоненте,
|
||||||
|
* реализующем этот интерфейс. Внутри компонент хранит
|
||||||
|
* [ConversationHandle] (или контекст вокруг него) и подписывается на
|
||||||
|
* нужные события.
|
||||||
|
*
|
||||||
|
* Компонент без [ConversationAware] остаётся чисто agent-level — он
|
||||||
|
* не получает per-conversation хуков.
|
||||||
|
*/
|
||||||
|
interface ConversationAware {
|
||||||
|
fun attachConversation(handle: ConversationHandle)
|
||||||
|
fun detachConversation(handle: ConversationHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальное окно в разговор, которое компонент видит через
|
||||||
|
* [ConversationAware]. Содержит только то, что нужно большинству
|
||||||
|
* per-conversation компонентов:
|
||||||
|
* - идентификатор (для подписки на события),
|
||||||
|
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
|
||||||
|
* - последние N turns (для LlmReflector / SkillMiner).
|
||||||
|
*
|
||||||
|
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
|
||||||
|
* чтобы компонент не лез в чужие обязанности.
|
||||||
|
*/
|
||||||
|
interface ConversationHandle : AutoCloseable {
|
||||||
|
val id: String
|
||||||
|
val isTemporal: Boolean
|
||||||
|
|
||||||
|
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
|
||||||
|
suspend fun recentTurns(limit: Int): List<ConversationTurn>
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
|
||||||
|
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
|
||||||
|
* компоненты (skill mining, reflection) могли передавать его напрямую
|
||||||
|
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
|
||||||
|
* конвертации.
|
||||||
|
*/
|
||||||
|
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настраиваемая версия [Agent]: расширяет публичный contract агента
|
||||||
|
* install/uninstall-механикой компонентов ([Component]).
|
||||||
|
*
|
||||||
|
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
|
||||||
|
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
|
||||||
|
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
|
||||||
|
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
|
||||||
|
*
|
||||||
|
* Контракт:
|
||||||
|
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
|
||||||
|
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
|
||||||
|
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
|
||||||
|
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
|
||||||
|
*
|
||||||
|
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
|
||||||
|
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
|
||||||
|
* позже, в v1 держим KISS.
|
||||||
|
*/
|
||||||
|
interface MutableAgent : Agent {
|
||||||
|
/**
|
||||||
|
* Провайдеры секций system prompt, регистрируются компонентами через [install].
|
||||||
|
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
|
||||||
|
* system prompt конкретной беседы; возвращает `null`, если у него нет
|
||||||
|
* релевантной секции для данного контекста.
|
||||||
|
*
|
||||||
|
* Изменяется **только внутри `Component.install(this)` /
|
||||||
|
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
|
||||||
|
* напрямую в список не лезет.
|
||||||
|
*/
|
||||||
|
val systemProviders: MutableList<SystemPromptProvider>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдеры tools, регистрируются компонентами через [install].
|
||||||
|
* [ToolProvider.tools] вызывается при формировании набора тулов
|
||||||
|
* для конкретной беседы; компонент решает сам, какие тулы отдавать
|
||||||
|
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
|
||||||
|
*/
|
||||||
|
val toolProviders: MutableList<ToolProvider>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливает [component] в агент: `component.install(this)` +
|
||||||
|
* агент запоминает компонент, чтобы при [close] корректно его снять.
|
||||||
|
*
|
||||||
|
* Возвращает `this` — для fluent-цепочек:
|
||||||
|
* ```
|
||||||
|
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun install(component: Component): MutableAgent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снимает [component]: `component.uninstall(this)` + забывает.
|
||||||
|
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
|
||||||
|
*/
|
||||||
|
fun uninstall(component: Component): MutableAgent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оповещает все установленные компоненты, реализующие [ConversationAware],
|
||||||
|
* о появлении нового разговора. Компонент может подписаться на события,
|
||||||
|
* запустить фоновые задачи, проиндексировать turns и т.п.
|
||||||
|
*/
|
||||||
|
fun attachConversation(handle: ConversationHandle)
|
||||||
|
|
||||||
|
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
|
||||||
|
fun detachConversation(handle: ConversationHandle)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Освобождает ресурсы агента и снимает все установленные компоненты
|
||||||
|
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
|
||||||
|
* первым). Idempotent.
|
||||||
|
*/
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдер одной секции system prompt конкретной беседы.
|
||||||
|
*
|
||||||
|
* Вызывается [MutableAgent] при каждом построении system prompt
|
||||||
|
* (на старте беседы и после значимых изменений контекста). Возвращает
|
||||||
|
* либо markdown-строку секции (будет вставлена в system prompt в порядке
|
||||||
|
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
|
||||||
|
* либо `null`, если у провайдера нет релевантной секции для данного
|
||||||
|
* контекста (например, skill-каталог пуст).
|
||||||
|
*
|
||||||
|
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
|
||||||
|
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
|
||||||
|
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
|
||||||
|
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
|
||||||
|
* страх и риск, **не** рекомендуется в v1).
|
||||||
|
*/
|
||||||
|
fun interface SystemPromptProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
|
||||||
|
*
|
||||||
|
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
|
||||||
|
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
|
||||||
|
* если провайдер хочет делать per-conversation разделение, он может.
|
||||||
|
*/
|
||||||
|
fun getSection(conversationId: String): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдер набора тулов конкретной беседы.
|
||||||
|
*
|
||||||
|
* Вызывается [MutableAgent] при формировании списка тулов, доступных
|
||||||
|
* модели в данной беседе (на старте и при пересборке после существенных
|
||||||
|
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
|
||||||
|
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
|
||||||
|
* из `describe()` / `invoke()` того же объекта.
|
||||||
|
*
|
||||||
|
* Не-suspend: типичная реализация строит список тулов из in-memory state
|
||||||
|
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
|
||||||
|
* к state компонент использует свой собственный scope и кэш.
|
||||||
|
*
|
||||||
|
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
|
||||||
|
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
|
||||||
|
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
|
||||||
|
* лишним слоем и удалён.
|
||||||
|
*/
|
||||||
|
fun interface ToolProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает список тулов, доступных модели в беседе [conversationId].
|
||||||
|
*
|
||||||
|
* Провайдер может делать per-conversation фильтрацию (например, скрывать
|
||||||
|
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
|
||||||
|
* пустой список.
|
||||||
|
*/
|
||||||
|
fun getTools(conversationId: String): List<LiteTool>
|
||||||
|
}
|
||||||
@@ -24,9 +24,12 @@ kotlin {
|
|||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
api(project(":reflection-api"))
|
api(project(":reflection-api"))
|
||||||
api(project(":context-api"))
|
api(project(":context-api"))
|
||||||
|
api(project(":agent-api"))
|
||||||
|
|
||||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
// liteTool DSL (типизированные LiteTool через @Serializable args)
|
||||||
|
api(libs.litert.tools.kotlinx.serialization)
|
||||||
|
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
|
|||||||
+12
-12
@@ -1,5 +1,6 @@
|
|||||||
package pw.binom.agentik.toolsets
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
|
|||||||
*/
|
*/
|
||||||
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
val tool: LiteTool = syncLiteTool(
|
val tool: LiteTool = liteToolSuspend<DisableArgs>(
|
||||||
describeJson = DESCRIBE,
|
name = NAME,
|
||||||
handler = ::invoke,
|
description = "Deactivate a toolset by name. Its tools become unavailable.",
|
||||||
)
|
) { args ->
|
||||||
|
invoke(args)
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun invoke(args: String): String {
|
internal suspend fun invoke(args: DisableArgs): String {
|
||||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
val name = args.name
|
||||||
val toolset = registry.findByName(name)
|
val toolset = registry.findByName(name)
|
||||||
if (toolset != null) {
|
if (toolset != null) {
|
||||||
// Единообразный ответ независимо от текущего состояния.
|
|
||||||
registry.deactivate(name)
|
registry.deactivate(name)
|
||||||
return "Toolset '$name' deactivated."
|
return "Toolset '$name' deactivated."
|
||||||
}
|
}
|
||||||
// Неизвестный — перечисляем активные (что можно деактивировать)
|
|
||||||
val actives = registry.activeNames()
|
val actives = registry.activeNames()
|
||||||
return if (actives.isEmpty()) {
|
return if (actives.isEmpty()) {
|
||||||
"Toolset '$name' not found. No toolsets to deactivate."
|
"Toolset '$name' not found. No toolsets to deactivate."
|
||||||
@@ -39,11 +40,10 @@ class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class DisableArgs(val name: String)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME: String = "disable_toolset"
|
const val NAME: String = "disable_toolset"
|
||||||
|
|
||||||
internal val DESCRIBE: String = """
|
|
||||||
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-26
@@ -1,8 +1,6 @@
|
|||||||
package pw.binom.agentik.toolsets
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
|
|||||||
*/
|
*/
|
||||||
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
val tool: LiteTool = syncLiteTool(
|
val tool: LiteTool = liteToolSuspend<EnableArgs>(
|
||||||
describeJson = DESCRIBE,
|
name = NAME,
|
||||||
handler = ::invoke,
|
description = "Activate a toolset by name to access its tools.",
|
||||||
)
|
) { args ->
|
||||||
|
invoke(args)
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun invoke(args: String): String {
|
internal suspend fun invoke(args: EnableArgs): String {
|
||||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
val name = args.name
|
||||||
val toolset = registry.findByName(name)
|
val toolset = registry.findByName(name)
|
||||||
if (toolset != null) {
|
if (toolset != null) {
|
||||||
val wasActive = registry.isActive(name)
|
val wasActive = registry.isActive(name)
|
||||||
registry.activate(name)
|
registry.activate(name)
|
||||||
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
||||||
}
|
}
|
||||||
// Неизвестный — перечисляем доступные к активации (inactives)
|
|
||||||
val inactives = registry.inactiveNames()
|
val inactives = registry.inactiveNames()
|
||||||
return if (inactives.isEmpty()) {
|
return if (inactives.isEmpty()) {
|
||||||
"Toolset '$name' not found. No toolsets available for activation."
|
"Toolset '$name' not found. No toolsets available for activation."
|
||||||
@@ -42,23 +41,10 @@ class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class EnableArgs(val name: String)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME: String = "enable_toolset"
|
const val NAME: String = "enable_toolset"
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
|
|
||||||
* Соответствует litert-kmp формату LiteTool.describe().
|
|
||||||
*/
|
|
||||||
internal val DESCRIBE: String = """
|
|
||||||
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
|
|
||||||
* Возвращает null если отсутствует или не строка.
|
|
||||||
*/
|
|
||||||
internal fun parseName(argsJson: String): String? = runCatching {
|
|
||||||
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
|
|
||||||
}.getOrNull()
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package pw.binom.agentik.toolsets
|
|
||||||
|
|
||||||
import pw.binom.litert.LiteTool
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (имя-как-видит-модель) → [LiteTool].
|
|
||||||
*
|
|
||||||
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
|
|
||||||
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
|
|
||||||
* чтобы избежать коллизий между разными MCP-серверами.
|
|
||||||
*
|
|
||||||
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
|
|
||||||
* должен жить рядом с другими тулами в `:agent-toolsets`.
|
|
||||||
*/
|
|
||||||
data class NamedTool(val name: String, val tool: LiteTool)
|
|
||||||
@@ -2,9 +2,10 @@ package pw.binom.agentik.toolsets
|
|||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
import pw.binom.litert.tools.kotlinx.serialization.liteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Адаптер из suspend-handler'а в синхронный [LiteTool].
|
* Обёртка из suspend-handler'а в синхронный [LiteTool].
|
||||||
*
|
*
|
||||||
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
||||||
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
||||||
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
|
|||||||
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
||||||
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
||||||
*
|
*
|
||||||
|
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
|
||||||
|
* который генерит JSON-схему из `@Serializable Args` через
|
||||||
|
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
|
||||||
|
* тулов, чьи описания не получается выразить через `Args` (например, динамические
|
||||||
|
* JSON Schema, приходящие со стороны).
|
||||||
|
*
|
||||||
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
||||||
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
||||||
*/
|
*/
|
||||||
internal class SyncLiteTool(
|
internal class SyncLiteTool(
|
||||||
|
override val name: String,
|
||||||
private val describeJson: String,
|
private val describeJson: String,
|
||||||
private val handler: suspend (String) -> String,
|
private val handler: suspend (String) -> String,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
|
* Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
|
||||||
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
|
* JSON-схема генерится автоматически из `Args.descriptor`,
|
||||||
* как API-точка чтобы внешний код не зависел от internal-имени класса.
|
* а сырая строка аргументов десериализуется в типизированный [Args].
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* ```
|
||||||
|
* val t: LiteTool = liteToolSuspend<MyArgs>(name = "foo", description = "...") { args ->
|
||||||
|
* suspendBlock(args) // MyArgs уже распарсен
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Реализация: под капотом используется [pw.binom.litert.tools.kotlinx.serialization.liteTool] —
|
||||||
|
* его sync-handler запускает наш suspend-handler в [runBlocking].
|
||||||
*/
|
*/
|
||||||
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
|
inline fun <reified Args> liteToolSuspend(
|
||||||
SyncLiteTool(describeJson, handler)
|
name: String,
|
||||||
|
description: String = "",
|
||||||
|
noinline handler: suspend (Args) -> String,
|
||||||
|
): LiteTool = liteTool<Args>(
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
) { args ->
|
||||||
|
runBlocking { handler(args) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal val invocationJson: kotlinx.serialization.json.Json = kotlinx.serialization.json.Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = false
|
||||||
|
coerceInputValues = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import pw.binom.agentik.agent.Component
|
||||||
|
import pw.binom.agentik.agent.MutableAgent
|
||||||
|
import pw.binom.agentik.agent.SystemPromptProvider
|
||||||
|
import pw.binom.agentik.agent.ToolProvider
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключает механику toolsets к агенту:
|
||||||
|
* - [ToolsetRegistry] (per-component instance — раньше жил в ChatAgent).
|
||||||
|
* - Тулы [EnableToolsetTool] и [DisableToolsetTool] всегда доступны — модель
|
||||||
|
* ими переключает состояние.
|
||||||
|
* - Тулы активных тулсетов — динамически: после `enable_toolset(name=X)`
|
||||||
|
* X.tools становятся видны через [ToolProvider.getTools] уже на
|
||||||
|
* следующем turn'е.
|
||||||
|
* - Секция системного промпта — список активных/неактивных тулсетов,
|
||||||
|
* чтобы модель знала что включено.
|
||||||
|
*
|
||||||
|
* Один [ToolsetComponent] на агента. Шарится между беседами через общий
|
||||||
|
* [MutableAgent] (все conversations читают один [ToolsetRegistry]).
|
||||||
|
*
|
||||||
|
* `install(agent)` идемпотентно. `uninstall(agent)` снимает оба провайдера
|
||||||
|
* по типу (см. [ToolsetToolProvider], [ToolsetSystemProvider]).
|
||||||
|
*/
|
||||||
|
class ToolsetComponent(
|
||||||
|
private val contributions: List<ToolsetContribution>,
|
||||||
|
) : Component {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр тулсетов, владеет [ToolsetComponent]. `private` — наружу не светится,
|
||||||
|
* чтобы никто не дёргал его мимо `enable_toolset`/`disable_toolset` тулов.
|
||||||
|
*/
|
||||||
|
private val registry: ToolsetRegistry = ToolsetRegistry(contributions)
|
||||||
|
|
||||||
|
private var provider: ToolsetToolProvider? = null
|
||||||
|
|
||||||
|
override fun install(agent: MutableAgent) {
|
||||||
|
val p = ToolsetToolProvider(registry, contributions)
|
||||||
|
agent.toolProviders.add(p)
|
||||||
|
provider = p
|
||||||
|
agent.systemProviders.add(ToolsetSystemProvider(registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uninstall(agent: MutableAgent) {
|
||||||
|
provider?.let { agent.toolProviders.remove(it) }
|
||||||
|
agent.systemProviders.removeAll { it is ToolsetSystemProvider }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает тулсет-тулы в зависимости от текущего состояния реестра:
|
||||||
|
* - `enable_toolset` / `disable_toolset` — всегда.
|
||||||
|
* - Тулы активных тулсетов — те, что перечислены в [ToolsetRegistry.activeNames].
|
||||||
|
*
|
||||||
|
* Snapshot собирается на каждом вызове [getTools] — диспетчер видит свежее
|
||||||
|
* состояние после `enable_toolset` уже на следующем turn'е.
|
||||||
|
*/
|
||||||
|
class ToolsetToolProvider(
|
||||||
|
private val registry: ToolsetRegistry,
|
||||||
|
private val contributions: List<ToolsetContribution>,
|
||||||
|
) : ToolProvider {
|
||||||
|
|
||||||
|
override fun getTools(conversationId: String): List<LiteTool> = buildList {
|
||||||
|
add(EnableToolsetTool(registry).tool)
|
||||||
|
add(DisableToolsetTool(registry).tool)
|
||||||
|
// Активные тулсеты — добавляем их тулы в общий пул. Это синхронная
|
||||||
|
// версия (lock-free snapshot), потому что `getTools` вызывается
|
||||||
|
// синхронно из `collectTools()`; `active` сам по себе Concurrent-Set
|
||||||
|
// через Mutex в реестре (все мутации — через activate/deactivate).
|
||||||
|
val active = runBlockingSnapshot()
|
||||||
|
contributions.filter { it.name in active }.forEach { c ->
|
||||||
|
c.tools.forEach { add(it.tool) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снимает снимок активных имён без suspend-блокировки.
|
||||||
|
* ToolsetRegistry.activeNames() — suspend, но его можно обойти если
|
||||||
|
* вычислить через прямой snapshot — для простоты используем runBlocking.
|
||||||
|
* Это всё равно вызывается на каждый turn, но мьютекс короткий.
|
||||||
|
*/
|
||||||
|
private fun runBlockingSnapshot(): Set<String> = kotlinx.coroutines.runBlocking {
|
||||||
|
registry.activeNames().toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция системного промпта с описанием доступных тулсетов:
|
||||||
|
* - `*active*` — что уже подключено.
|
||||||
|
* - `*inactive*` — что доступно через `enable_toolset`.
|
||||||
|
*/
|
||||||
|
class ToolsetSystemProvider(
|
||||||
|
private val registry: ToolsetRegistry,
|
||||||
|
) : SystemPromptProvider {
|
||||||
|
override fun getSection(conversationId: String): String {
|
||||||
|
val activeNames = kotlinx.coroutines.runBlocking { registry.activeNames() }.toSet()
|
||||||
|
val all = registry.all()
|
||||||
|
val active = all.filter { it.name in activeNames }
|
||||||
|
val inactive = all.filter { it.name !in activeNames }
|
||||||
|
return SystemPromptToolsetSection.render(active = active, inactive = inactive) ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-12
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
|
|||||||
class DisableToolsetToolTest {
|
class DisableToolsetToolTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok"
|
override fun invoke(arguments: String) = "ok"
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
|
|||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("media")
|
reg.activate("media")
|
||||||
val r = disable.invoke("""{"name":"media"}""")
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' deactivated.", r)
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
assertEquals(false, reg.isActive("media"))
|
assertEquals(false, reg.isActive("media"))
|
||||||
}
|
}
|
||||||
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
|
|||||||
val (disable, _) = harness(listOf(
|
val (disable, _) = harness(listOf(
|
||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||||
val r = disable.invoke("""{"name":"media"}""")
|
|
||||||
assertEquals("Toolset 'media' deactivated.", r)
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
|
|||||||
))
|
))
|
||||||
reg.activate("a")
|
reg.activate("a")
|
||||||
reg.activate("b")
|
reg.activate("b")
|
||||||
val r = disable.invoke("""{"name":"unknown"}""")
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
|
|||||||
val (disable, _) = harness(listOf(
|
val (disable, _) = harness(listOf(
|
||||||
ToolsetContribution("a", "x", emptyList()),
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
))
|
))
|
||||||
val r = disable.invoke("""{"name":"unknown"}""")
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
|
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `missing name argument returns error message`() = runTest {
|
|
||||||
val (disable, _) = harness(emptyList())
|
|
||||||
val r = disable.invoke("""{}""")
|
|
||||||
assertEquals("missing required argument 'name'", r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-11
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
|
|||||||
class EnableToolsetToolTest {
|
class EnableToolsetToolTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok"
|
override fun invoke(arguments: String) = "ok"
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
|
|||||||
val (enable, reg) = harness(listOf(
|
val (enable, reg) = harness(listOf(
|
||||||
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
||||||
))
|
))
|
||||||
val r = enable.invoke("""{"name":"media"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' activated.", r)
|
assertEquals("Toolset 'media' activated.", r)
|
||||||
assertEquals(true, reg.isActive("media"))
|
assertEquals(true, reg.isActive("media"))
|
||||||
}
|
}
|
||||||
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("media")
|
reg.activate("media")
|
||||||
val r = enable.invoke("""{"name":"media"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' already active.", r)
|
assertEquals("Toolset 'media' already active.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("b", "y", emptyList()),
|
ToolsetContribution("b", "y", emptyList()),
|
||||||
ToolsetContribution("c", "z", emptyList()),
|
ToolsetContribution("c", "z", emptyList()),
|
||||||
))
|
))
|
||||||
val r = enable.invoke("""{"name":"unknown"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("a", "x", emptyList()),
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("a")
|
reg.activate("a")
|
||||||
val r = enable.invoke("""{"name":"unknown"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
|
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `missing name argument returns error message`() = runTest {
|
|
||||||
val (enable, _) = harness(emptyList())
|
|
||||||
val r = enable.invoke("""{}""")
|
|
||||||
assertEquals("missing required argument 'name'", r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ import kotlin.test.assertTrue
|
|||||||
class ToolsetDispatchPolicyTest {
|
class ToolsetDispatchPolicyTest {
|
||||||
|
|
||||||
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = response
|
override fun invoke(arguments: String) = response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
|
|||||||
class ToolsetRegistryTest {
|
class ToolsetRegistryTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok:$name"
|
override fun invoke(arguments: String) = "ok:$name"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -47,8 +47,8 @@ val moduleDescriptions: Map<String, String> = mapOf(
|
|||||||
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
||||||
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
||||||
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
||||||
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
|
"storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
|
||||||
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
"storage-sqlite" to "agentik :storage-sqlite — исторический модуль (deleted 2026-09-22; ksqlite-реализации теперь живут в :journal-ksqlite / :context-ksqlite / :reflection-ksqlite).",
|
||||||
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||||
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
||||||
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
||||||
|
|||||||
+1
-1
@@ -444,7 +444,7 @@ agent.deleteConversation(conv.id) // → DELETE /conversations/
|
|||||||
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
|
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
|
||||||
после перезапуска) — реализуй свой `MutableConversationStore` поверх
|
после перезапуска) — реализуй свой `MutableConversationStore` поверх
|
||||||
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
|
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
|
||||||
`:storage-ksqlite` как образец.
|
`:journal-ksqlite` как образец.
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import pw.binom.agentik.journal.MutableConversationStore
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ plugins {
|
|||||||
// агента.
|
// агента.
|
||||||
//
|
//
|
||||||
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
// на Linux (см. KDoc :storage-ksqlite).
|
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|||||||
+39
-9
@@ -17,20 +17,41 @@ import kotlinx.coroutines.withContext
|
|||||||
/**
|
/**
|
||||||
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
|
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
|
||||||
*
|
*
|
||||||
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
|
* Единственный владелец таблицы `working_memory` в проекте. Используется
|
||||||
* из `:storage-ksqlite`, но:
|
* напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
|
||||||
* - лежит в собственном модуле `:context-ksqlite`;
|
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
|
||||||
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
|
|
||||||
* теперь главный класс — `ContextStore`); сами типы строк
|
|
||||||
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
|
|
||||||
*
|
*
|
||||||
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
|
* ## Lifecycle соединения
|
||||||
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
|
*
|
||||||
|
* Семантика владения connection'ом идентична
|
||||||
|
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
|
||||||
|
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
|
||||||
|
* закрывает его в [close].
|
||||||
|
* - `KsqliteContextStore(path)` — открывает файловое соединение,
|
||||||
|
* закрывает его в [close].
|
||||||
|
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
|
||||||
|
*
|
||||||
|
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
|
||||||
*/
|
*/
|
||||||
class KsqliteContextStore(
|
class KsqliteContextStore private constructor(
|
||||||
private val connection: SQLiteConnection,
|
private val connection: SQLiteConnection,
|
||||||
|
private val ownsConnection: Boolean,
|
||||||
) : ContextStore {
|
) : ContextStore {
|
||||||
|
|
||||||
|
constructor(path: String) : this(
|
||||||
|
connection = SQLiteConnection.open(path = path),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(connection: SQLiteConnection) : this(
|
||||||
|
connection = connection,
|
||||||
|
ownsConnection = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Schema.migrate(connection)
|
||||||
|
}
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@@ -179,6 +200,15 @@ class KsqliteContextStore(
|
|||||||
maxOrderIdxStmt.close()
|
maxOrderIdxStmt.close()
|
||||||
dropFromIdxStmt.close()
|
dropFromIdxStmt.close()
|
||||||
insertSummaryStmt.close()
|
insertSummaryStmt.close()
|
||||||
|
if (ownsConnection) connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun memory(name: String? = null): KsqliteContextStore =
|
||||||
|
KsqliteContextStore(
|
||||||
|
connection = SQLiteConnection.memory(name),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maxOrderIdx(conversationId: String): Long {
|
private fun maxOrderIdx(conversationId: String): Long {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pw.binom.db.ksqlite.SQLiteConnection
|
|||||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||||
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||||
*/
|
*/
|
||||||
internal object Schema {
|
object Schema {
|
||||||
|
|
||||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||||
const val CURRENT_VERSION: Int = 1
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|||||||
+3
-11
@@ -12,16 +12,9 @@ import kotlin.test.assertTrue
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тесты для [KsqliteContextStore] — точная копия
|
* Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
|
||||||
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием
|
* SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
|
||||||
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для
|
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||||
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
|
|
||||||
* та же).
|
|
||||||
*
|
|
||||||
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
|
|
||||||
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
|
|
||||||
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
|
|
||||||
* автономный.
|
|
||||||
*/
|
*/
|
||||||
class KsqliteContextStoreTest {
|
class KsqliteContextStoreTest {
|
||||||
|
|
||||||
@@ -31,7 +24,6 @@ class KsqliteContextStoreTest {
|
|||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
|
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
|
||||||
Schema.migrate(conn)
|
|
||||||
store = KsqliteContextStore(conn)
|
store = KsqliteContextStore(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ kotlinx-io = "0.8.0"
|
|||||||
ktor = "3.1.3"
|
ktor = "3.1.3"
|
||||||
a2a = "1.0.0-SNAPSHOT"
|
a2a = "1.0.0-SNAPSHOT"
|
||||||
kaml = "0.104.0"
|
kaml = "0.104.0"
|
||||||
litert = "8"
|
litert = "13"
|
||||||
sqldelight = "2.3.2"
|
sqldelight = "2.3.2"
|
||||||
shadow = "8.3.5"
|
shadow = "8.3.5"
|
||||||
jvector = "3.0.6"
|
jvector = "3.0.6"
|
||||||
@@ -40,6 +40,9 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
|
|||||||
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
||||||
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
|
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
|
||||||
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
|
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
|
||||||
|
# liteTool(liteToolRaw) DSL: типизированные LiteTool через @Serializable args.
|
||||||
|
# https://git.binom.pw/subochev/litert-kmp/src/branch/main/litert-tools-kotlinx-serialization
|
||||||
|
litert-tools-kotlinx-serialization = { module = "pw.binom.litert:litert-tools-kotlinx-serialization", version.ref = "litert" }
|
||||||
|
|
||||||
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
||||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||||
@@ -109,5 +112,6 @@ text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "
|
|||||||
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
||||||
|
|
||||||
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||||
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" }
|
# KMP-артефакт (он же `kotlin-logging-jvm` существует отдельно как JVM-only build).
|
||||||
|
kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" }
|
||||||
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ interface JournalStore : AutoCloseable {
|
|||||||
|
|
||||||
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько сообщений в диалоге [conversationId] всего.
|
||||||
|
*
|
||||||
|
* O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`),
|
||||||
|
* O(N) на in-memory (size простого list'а с фильтром по conversationId).
|
||||||
|
* Не зависит от cursor'а [after] — для total-размера диалога.
|
||||||
|
*/
|
||||||
|
suspend fun count(conversationId: String): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько сообщений в диалоге [conversationId] создано **позже** [after]
|
||||||
|
* (строго `createdAt > after`, как и в [list]).
|
||||||
|
*
|
||||||
|
* O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для:
|
||||||
|
* - UI badge "N новых сообщений" — клиент знает последний `lastSeen`,
|
||||||
|
* сервер говорит `count(convId, after=lastSeen)`;
|
||||||
|
* - пагинации без получения самих записей: знаем лимит последней страницы,
|
||||||
|
* надо понять "есть ли ещё";
|
||||||
|
* - compaction-метрик: «сколько turn'ов осталось после cutoff».
|
||||||
|
*/
|
||||||
|
suspend fun count(conversationId: String, after: Instant): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
||||||
* (по странице через `list()` пока не получит короткую страницу). Для
|
* (по странице через `list()` пока не получит короткую страницу). Для
|
||||||
|
|||||||
@@ -14,4 +14,12 @@ package pw.binom.agentik.journal
|
|||||||
*/
|
*/
|
||||||
interface MutableJournalStore : JournalStore {
|
interface MutableJournalStore : JournalStore {
|
||||||
suspend fun append(record: MessageRecord)
|
suspend fun append(record: MessageRecord)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить все сообщения диалога [conversationId]. Используется
|
||||||
|
* владельцем lifecycle диалога при его удалении (каскад из
|
||||||
|
* ChatAgent.deleteConversation). Append-only природа audit log'а
|
||||||
|
* не нарушается — это bulk-clear, а не редактирование.
|
||||||
|
*/
|
||||||
|
suspend fun clear(conversationId: String)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -54,9 +54,17 @@ class InMemoryJournalStore : MutableJournalStore {
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сбросить кэш (например, когда диалог удалён). */
|
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
|
||||||
suspend fun clear(): Unit = mutex.withLock {
|
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
|
||||||
records.clear()
|
records.removeAll { it.conversationId == conversationId }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String): Long = mutex.withLock {
|
||||||
|
records.count { it.conversationId == conversationId }.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String, after: Instant): Long = mutex.withLock {
|
||||||
|
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ class InMemoryMutableConversationStore(
|
|||||||
private val clock: Clock = Clock.System,
|
private val clock: Clock = Clock.System,
|
||||||
) : MutableConversationStore {
|
) : MutableConversationStore {
|
||||||
|
|
||||||
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf()
|
private val byId = mutableMapOf<String, ConversationRecord>()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
override suspend fun upsert(record: ConversationRecord) {
|
override suspend fun upsert(record: ConversationRecord) {
|
||||||
|
|||||||
+86
-3
@@ -66,13 +66,96 @@ class InMemoryJournalStoreTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clear empties the cache`() = runTest {
|
fun `clear empties a conversation only`() = runTest {
|
||||||
val store = InMemoryJournalStore()
|
val store = InMemoryJournalStore()
|
||||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
store.append(userMsg("m1", "c1", "x", t0))
|
store.append(userMsg("m1", "c1", "x", t0))
|
||||||
|
store.append(userMsg("m2", "c2", "y", t0))
|
||||||
|
assertEquals(2, store.size())
|
||||||
|
store.clear("c1")
|
||||||
assertEquals(1, store.size())
|
assertEquals(1, store.size())
|
||||||
store.clear()
|
|
||||||
assertEquals(0, store.size())
|
|
||||||
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
|
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
|
||||||
|
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count returns total per conversation`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
assertEquals(0L, store.count("c1"))
|
||||||
|
|
||||||
|
store.append(userMsg("m1", "c1", "a", t0))
|
||||||
|
store.append(userMsg("m2", "c1", "b", t0 + 1.seconds))
|
||||||
|
store.append(userMsg("m3", "c2", "c", t0 + 2.seconds))
|
||||||
|
|
||||||
|
assertEquals(2L, store.count("c1"))
|
||||||
|
assertEquals(1L, store.count("c2"))
|
||||||
|
assertEquals(0L, store.count("never-existed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count is unaffected by clear of another conversation`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
for (i in 1..20) {
|
||||||
|
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
|
||||||
|
}
|
||||||
|
store.append(userMsg("n1", "c2", "y", t0))
|
||||||
|
assertEquals(20L, store.count("c1"))
|
||||||
|
assertEquals(1L, store.count("c2"))
|
||||||
|
|
||||||
|
store.clear("c1")
|
||||||
|
assertEquals(0L, store.count("c1"))
|
||||||
|
assertEquals(1L, store.count("c2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count after cursor excludes earlier messages`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "a", t0))
|
||||||
|
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
|
||||||
|
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
|
||||||
|
|
||||||
|
// strictly > t0
|
||||||
|
assertEquals(2L, store.count("c1", t0))
|
||||||
|
// strictly > t0+10s — only the last
|
||||||
|
assertEquals(1L, store.count("c1", t0 + 10.seconds))
|
||||||
|
// after last — empty
|
||||||
|
assertEquals(0L, store.count("c1", t0 + 20.seconds))
|
||||||
|
// distant past — all
|
||||||
|
assertEquals(3L, store.count("c1", Instant.DISTANT_PAST))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count after cursor scopes to conversation`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "a", t))
|
||||||
|
store.append(userMsg("m2", "c2", "b", t))
|
||||||
|
|
||||||
|
val future = Instant.parse("2099-01-01T00:00:00Z")
|
||||||
|
assertEquals(0L, store.count("c1", future))
|
||||||
|
assertEquals(0L, store.count("c2", future))
|
||||||
|
assertEquals(1L, store.count("c1", Instant.DISTANT_PAST))
|
||||||
|
assertEquals(1L, store.count("c2", Instant.DISTANT_PAST))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count agrees with list size`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
for (i in 1..15) {
|
||||||
|
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
store.list("c1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
|
||||||
|
store.count("c1"),
|
||||||
|
)
|
||||||
|
val cutoff = t0 + 7.seconds
|
||||||
|
assertEquals(
|
||||||
|
store.list("c1", cutoff, 0, 1000).size.toLong(),
|
||||||
|
store.count("c1", cutoff),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ plugins {
|
|||||||
// собственных ksqlite-модулях.
|
// собственных ksqlite-модулях.
|
||||||
//
|
//
|
||||||
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
// на Linux (см. KDoc :storage-ksqlite).
|
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|||||||
+108
-17
@@ -14,31 +14,67 @@ import kotlinx.coroutines.withContext
|
|||||||
/**
|
/**
|
||||||
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
|
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
|
||||||
*
|
*
|
||||||
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore]
|
* Единственный класс для message-таблицы. Используется напрямую через
|
||||||
* из `:storage-ksqlite`, но:
|
* `:journal-ksqlite` зависимость; bundle'ом собирает
|
||||||
* - лежит в собственном модуле `:journal-ksqlite`;
|
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
|
||||||
* - реализует переименованный [MutableJournalStore] (раньше был
|
*
|
||||||
* `MutableMessageStore`, теперь главный класс — `JournalStore` /
|
* ## Lifecycle соединения
|
||||||
* `MutableJournalStore`); сам тип записи [MessageRecord] не
|
*
|
||||||
* переименовывался.
|
* Три формы конструктора с разной семантикой владения:
|
||||||
|
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
|
||||||
|
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
|
||||||
|
* где один connection используется многими store'ами и закрывается bundle'ом.
|
||||||
|
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
|
||||||
|
* его в [close].
|
||||||
|
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
|
||||||
|
* закрывает его в [close].
|
||||||
|
*
|
||||||
|
* ## Миграция
|
||||||
|
*
|
||||||
|
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
||||||
|
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
|
||||||
|
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
|
||||||
|
* прогоняют миграцию одной и той же схемы по очереди.
|
||||||
*
|
*
|
||||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||||
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
|
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
||||||
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
|
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
|
||||||
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
|
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
|
||||||
|
* (см. [pw.binom.db.ksqlite.StmtHolder]).
|
||||||
*
|
*
|
||||||
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
|
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
|
||||||
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
|
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
|
||||||
* в [MessageCodecs.kt] рядом.
|
* в [MessageCodecs.kt] рядом.
|
||||||
*
|
|
||||||
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
|
|
||||||
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
|
|
||||||
* отдельно.
|
|
||||||
*/
|
*/
|
||||||
class KsqliteJournalStore internal constructor(
|
class KsqliteJournalStore private constructor(
|
||||||
private val connection: SQLiteConnection,
|
private val connection: SQLiteConnection,
|
||||||
|
private val ownsConnection: Boolean,
|
||||||
) : MutableJournalStore {
|
) : MutableJournalStore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
|
||||||
|
* себя его закрытие в [close]. Для standalone использования, когда у
|
||||||
|
* store'а нет bundle'а-владельца connection'а.
|
||||||
|
*/
|
||||||
|
constructor(path: String) : this(
|
||||||
|
connection = SQLiteConnection.open(path = path),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Внешнее соединение — store НЕ закрывает его в [close]. Для
|
||||||
|
* shared-connection bundles (`SqliteStores.assemble`), где один
|
||||||
|
* connection используется многими store'ами и закрывается bundle'ом.
|
||||||
|
*/
|
||||||
|
constructor(connection: SQLiteConnection) : this(
|
||||||
|
connection = connection,
|
||||||
|
ownsConnection = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Schema.migrate(connection)
|
||||||
|
}
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@@ -64,6 +100,16 @@ class KsqliteJournalStore internal constructor(
|
|||||||
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
)
|
)
|
||||||
|
private val countAllStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
|
)
|
||||||
|
private val countAfterStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
AND ${Schema.COL_CREATED_AT} > ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||||
val (kind, payload) = encodeRecord(record)
|
val (kind, payload) = encodeRecord(record)
|
||||||
@@ -94,13 +140,15 @@ class KsqliteJournalStore internal constructor(
|
|||||||
listStmt.bindLong(4, offset.toLong())
|
listStmt.bindLong(4, offset.toLong())
|
||||||
val out = mutableListOf<MessageRecord>()
|
val out = mutableListOf<MessageRecord>()
|
||||||
listStmt.executeQuery().use { rs ->
|
listStmt.executeQuery().use { rs ->
|
||||||
while (rs.next()) out.add(rs.toMessageRecord(json))
|
while (rs.next()) {
|
||||||
|
out.add(rs.toMessageRecord(json))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
clearStmt.reset()
|
clearStmt.reset()
|
||||||
clearStmt.clearBindings()
|
clearStmt.clearBindings()
|
||||||
@@ -109,9 +157,52 @@ class KsqliteJournalStore internal constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String): Long = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
countAllStmt.reset()
|
||||||
|
countAllStmt.clearBindings()
|
||||||
|
countAllStmt.bindText(1, conversationId)
|
||||||
|
countAllStmt.executeQuery().use { rs ->
|
||||||
|
check(rs.next()) { "COUNT(*) must return at least one row" }
|
||||||
|
(rs.getLong(0) ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String, after: Instant): Long = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
countAfterStmt.reset()
|
||||||
|
countAfterStmt.clearBindings()
|
||||||
|
countAfterStmt.bindText(1, conversationId)
|
||||||
|
countAfterStmt.bindLong(2, after.toEpochMilliseconds())
|
||||||
|
countAfterStmt.executeQuery().use { rs ->
|
||||||
|
check(rs.next()) { "COUNT(*) must return at least one row" }
|
||||||
|
(rs.getLong(0) ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
insertStmt.close()
|
insertStmt.close()
|
||||||
listStmt.close()
|
listStmt.close()
|
||||||
clearStmt.close()
|
clearStmt.close()
|
||||||
|
countAllStmt.close()
|
||||||
|
countAfterStmt.close()
|
||||||
|
if (ownsConnection) {
|
||||||
|
connection.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Открывает in-memory соединение через [SQLiteConnection.memory] и
|
||||||
|
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
|
||||||
|
* runtime.
|
||||||
|
*/
|
||||||
|
fun memory(name: String? = null) =
|
||||||
|
KsqliteJournalStore(
|
||||||
|
connection = SQLiteConnection.memory(name),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-6
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage.ksqlite
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -14,13 +14,50 @@ import kotlinx.coroutines.withContext
|
|||||||
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
|
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
|
||||||
* в [Schema] (миграция через PRAGMA user_version) — этот класс только
|
* в [Schema] (миграция через PRAGMA user_version) — этот класс только
|
||||||
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
|
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
|
||||||
|
*
|
||||||
|
* Каскадное удаление связанных данных (message + working_memory) делает
|
||||||
|
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
|
||||||
|
* store знает только про свою таблицу.
|
||||||
|
*
|
||||||
|
* ## Lifecycle соединения
|
||||||
|
*
|
||||||
|
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
|
||||||
|
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
|
||||||
|
* store НЕ закрывает его в [close] (используется shared-connection
|
||||||
|
* bundle'ом `SqliteStores.assemble`).
|
||||||
|
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
|
||||||
|
* закрывает его в [close].
|
||||||
|
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
|
||||||
|
* в [close].
|
||||||
*/
|
*/
|
||||||
class KsqliteMutableConversationStore(
|
class KsqliteMutableConversationStore private constructor(
|
||||||
private val connection: SQLiteConnection,
|
private val connection: SQLiteConnection,
|
||||||
private val messageStore: KsqliteMessageStore? = null,
|
private val ownsConnection: Boolean,
|
||||||
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
|
|
||||||
) : MutableConversationStore {
|
) : MutableConversationStore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
|
||||||
|
* себя его закрытие в [close]. Для standalone использования, когда у
|
||||||
|
* store'а нет bundle'а-владельца connection'а.
|
||||||
|
*/
|
||||||
|
constructor(path: String) : this(
|
||||||
|
connection = SQLiteConnection.open(path = path),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Внешнее соединение — store НЕ закрывает его в [close]. Для
|
||||||
|
* shared-connection bundles (`SqliteStores.assemble`).
|
||||||
|
*/
|
||||||
|
constructor(connection: SQLiteConnection) : this(
|
||||||
|
connection = connection,
|
||||||
|
ownsConnection = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Schema.migrate(connection)
|
||||||
|
}
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
|
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
|
||||||
@@ -125,8 +162,6 @@ class KsqliteMutableConversationStore(
|
|||||||
// Проверяем существование через raw query, НЕ через get() — get() тоже
|
// Проверяем существование через raw query, НЕ через get() — get() тоже
|
||||||
// берёт mutex (не реентрант), что привело бы к deadlock.
|
// берёт mutex (не реентрант), что привело бы к deadlock.
|
||||||
if (!execExists(id)) return@withContext false
|
if (!execExists(id)) return@withContext false
|
||||||
messageStore?.clear(id)
|
|
||||||
workingMemoryStore?.clear(id)
|
|
||||||
deleteStmt.reset()
|
deleteStmt.reset()
|
||||||
deleteStmt.clearBindings()
|
deleteStmt.clearBindings()
|
||||||
deleteStmt.bindText(1, id)
|
deleteStmt.bindText(1, id)
|
||||||
@@ -188,6 +223,15 @@ class KsqliteMutableConversationStore(
|
|||||||
renameStmt.close()
|
renameStmt.close()
|
||||||
renameUpdatedAtStmt.close()
|
renameUpdatedAtStmt.close()
|
||||||
touchStmt.close()
|
touchStmt.close()
|
||||||
|
if (ownsConnection) connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun memory(name: String? = null): KsqliteMutableConversationStore =
|
||||||
|
KsqliteMutableConversationStore(
|
||||||
|
connection = SQLiteConnection.memory(name),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun execExists(id: String): Boolean {
|
private fun execExists(id: String): Boolean {
|
||||||
+2
-4
@@ -10,10 +10,8 @@ import kotlin.time.Instant
|
|||||||
/**
|
/**
|
||||||
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
|
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
|
||||||
*
|
*
|
||||||
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers
|
* `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
|
||||||
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
|
* не в каком-то внешнем общем модуле.
|
||||||
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
|
|
||||||
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
|
|
||||||
*/
|
*/
|
||||||
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
||||||
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
|
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
|
||||||
|
|||||||
@@ -5,32 +5,51 @@ import pw.binom.db.ksqlite.SQLiteConnection
|
|||||||
/**
|
/**
|
||||||
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
|
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
|
||||||
*
|
*
|
||||||
* Минимум — только то, что относится к `message` (append-only audit log).
|
* Владеет двумя таблицами:
|
||||||
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
|
* - `conversation` — реестр диалогов агента (см. ConversationRecord);
|
||||||
* живут в других ksqlite-модулях.
|
* - `message` — append-only audit log сообщений диалогов.
|
||||||
|
*
|
||||||
|
* `working_memory` и `reflection` живут в других ksqlite-модулях.
|
||||||
*
|
*
|
||||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||||
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||||
*/
|
*/
|
||||||
internal object Schema {
|
object Schema {
|
||||||
|
|
||||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||||
const val CURRENT_VERSION: Int = 1
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
// ───── Таблица ─────
|
// ───── Таблицы ─────
|
||||||
|
const val TABLE_CONVERSATION = "conversation"
|
||||||
const val TABLE_MESSAGE = "message"
|
const val TABLE_MESSAGE = "message"
|
||||||
|
|
||||||
// ───── Колонки ─────
|
// ───── Колонки conversation ─────
|
||||||
const val COL_ID = "id"
|
const val COL_ID = "id"
|
||||||
|
const val COL_TITLE = "title"
|
||||||
|
const val COL_IS_TEMPORAL = "is_temporal"
|
||||||
|
const val COL_CREATED_AT = "created_at"
|
||||||
|
const val COL_UPDATED_AT = "updated_at"
|
||||||
|
|
||||||
|
// ───── Колонки message ─────
|
||||||
const val COL_CONVERSATION_ID = "conversation_id"
|
const val COL_CONVERSATION_ID = "conversation_id"
|
||||||
const val COL_KIND = "kind"
|
const val COL_KIND = "kind"
|
||||||
const val COL_PAYLOAD_JSON = "payload_json"
|
const val COL_PAYLOAD_JSON = "payload_json"
|
||||||
const val COL_CREATED_AT = "created_at"
|
|
||||||
|
|
||||||
// ───── Индексы ─────
|
// ───── Индексы ─────
|
||||||
|
const val IDX_CONV_UPDATED = "idx_conv_updated"
|
||||||
const val IDX_MSG_CONV = "idx_msg_conv"
|
const val IDX_MSG_CONV = "idx_msg_conv"
|
||||||
|
|
||||||
private val v1Ddl = """
|
private val v1ConversationDdl = """
|
||||||
|
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
|
||||||
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
$COL_TITLE TEXT,
|
||||||
|
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
|
||||||
|
$COL_CREATED_AT INTEGER NOT NULL,
|
||||||
|
$COL_UPDATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
private val v1MessageDdl = """
|
||||||
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
||||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||||
@@ -38,54 +57,43 @@ internal object Schema {
|
|||||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||||
$COL_CREATED_AT INTEGER NOT NULL
|
$COL_CREATED_AT INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
""".trimIndent()
|
"""
|
||||||
|
|
||||||
private val v1IndexesDdl = """
|
private val v1IndexesDdl = """
|
||||||
|
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
||||||
|
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
||||||
|
|
||||||
|
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
|
||||||
|
-- сортировка по created_at (используется list(), cascade-clear, etc.)
|
||||||
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
||||||
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
|
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
|
||||||
""".trimIndent()
|
"""
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||||
*
|
*
|
||||||
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
|
* Гарантии:
|
||||||
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
|
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||||
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
|
* уже-мигрированной БД;
|
||||||
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
|
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||||
|
* ROLLBACK оставит БД консистентной.
|
||||||
*
|
*
|
||||||
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
|
||||||
|
* user_version как gate перестал работать — два модуля ставят его в 1,
|
||||||
|
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||||
|
* idempotently; координация multi-module миграций — ответственность
|
||||||
|
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
|
||||||
*/
|
*/
|
||||||
fun migrate(conn: SQLiteConnection) {
|
fun migrate(conn: SQLiteConnection) {
|
||||||
val current = readUserVersion(conn)
|
|
||||||
if (current >= CURRENT_VERSION) return
|
|
||||||
|
|
||||||
conn.exec("BEGIN")
|
conn.exec("BEGIN")
|
||||||
try {
|
try {
|
||||||
if (current < 1) {
|
conn.exec(v1ConversationDdl)
|
||||||
conn.exec(v1Ddl)
|
conn.exec(v1MessageDdl)
|
||||||
conn.exec(v1IndexesDdl)
|
conn.exec(v1IndexesDdl)
|
||||||
}
|
|
||||||
// future: if (current < 2) { conn.exec(v2Ddl) }
|
|
||||||
writeUserVersion(conn, CURRENT_VERSION)
|
|
||||||
conn.exec("COMMIT")
|
conn.exec("COMMIT")
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
runCatching { conn.exec("ROLLBACK") }
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
throw t
|
throw t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readUserVersion(conn: SQLiteConnection): Int {
|
|
||||||
conn.prepare("PRAGMA user_version").use { stmt ->
|
|
||||||
stmt.executeQuery().use { rs ->
|
|
||||||
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
|
||||||
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
|
|
||||||
// поэтому собираем SQL строкой (значение контролируемое, не user input).
|
|
||||||
conn.exec("PRAGMA user_version = $version")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-10
@@ -13,15 +13,9 @@ import kotlin.test.assertEquals
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тесты для [KsqliteJournalStore] — точная копия
|
* Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
|
||||||
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов
|
* SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
|
||||||
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory
|
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||||
* SQLiteConnection + Schema.migrate).
|
|
||||||
*
|
|
||||||
* Тест `testClearRemovesByConversation` из оригинала использовал
|
|
||||||
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
|
|
||||||
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
|
|
||||||
* автономен и не знает про ConversationStore.
|
|
||||||
*/
|
*/
|
||||||
class KsqliteJournalStoreTest {
|
class KsqliteJournalStoreTest {
|
||||||
|
|
||||||
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
|
|||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
|
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
|
||||||
Schema.migrate(conn)
|
|
||||||
store = KsqliteJournalStore(conn)
|
store = KsqliteJournalStore(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,4 +96,93 @@ class KsqliteJournalStoreTest {
|
|||||||
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
|
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
|
||||||
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
|
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCountReturnsTotalForConversation() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
assertEquals(0L, store.count("conv1"))
|
||||||
|
|
||||||
|
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m3", "conv2", listOf(Content.Text("c")), t, null))
|
||||||
|
|
||||||
|
assertEquals(2L, store.count("conv1"))
|
||||||
|
assertEquals(1L, store.count("conv2"))
|
||||||
|
assertEquals(0L, store.count("missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCountIsolatedFromOtherConversations() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
// Bulk insert into conv1, single into conv2.
|
||||||
|
for (i in 1..50) {
|
||||||
|
store.append(MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t, null))
|
||||||
|
}
|
||||||
|
store.append(MessageRecord.UserMessage("n1", "conv2", listOf(Content.Text("only")), t, null))
|
||||||
|
|
||||||
|
assertEquals(50L, store.count("conv1"))
|
||||||
|
assertEquals(1L, store.count("conv2"))
|
||||||
|
assertEquals(0L, store.count("never-existed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCountAfterFiltersStrictly() = runTest {
|
||||||
|
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||||
|
val t2 = Instant.parse("2026-09-15T10:02:00Z")
|
||||||
|
val t3 = Instant.parse("2026-09-15T10:03:00Z")
|
||||||
|
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
|
||||||
|
|
||||||
|
// Strictly > — t1 is not counted when after=t1.
|
||||||
|
assertEquals(2L, store.count("conv1", t1))
|
||||||
|
// Strictly > — t2 IS counted (only t3 after).
|
||||||
|
assertEquals(1L, store.count("conv1", t2))
|
||||||
|
// After last — empty.
|
||||||
|
assertEquals(0L, store.count("conv1", t3))
|
||||||
|
// Distant past — all three.
|
||||||
|
assertEquals(3L, store.count("conv1", Instant.DISTANT_PAST))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCountAfterIsolatesByConversation() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), t, null))
|
||||||
|
|
||||||
|
// Cursor that excludes everything — both conversations read 0.
|
||||||
|
val future = Instant.parse("2099-01-01T00:00:00Z")
|
||||||
|
assertEquals(0L, store.count("conv1", future))
|
||||||
|
assertEquals(0L, store.count("conv2", future))
|
||||||
|
// Cursor that includes everything — only conv1's message matches its conversation.
|
||||||
|
assertEquals(1L, store.count("conv1", Instant.DISTANT_PAST))
|
||||||
|
assertEquals(1L, store.count("conv2", Instant.DISTANT_PAST))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCountAgreesWithListSize() = runTest {
|
||||||
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
for (i in 1..10) {
|
||||||
|
store.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m$i",
|
||||||
|
conversationId = "conv1",
|
||||||
|
content = listOf(Content.Text("x$i")),
|
||||||
|
createdAt = t0 + kotlin.time.Duration.parse("PT${i}S"),
|
||||||
|
context = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// count === list(..., 0, +∞).size
|
||||||
|
assertEquals(
|
||||||
|
store.list("conv1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
|
||||||
|
store.count("conv1"),
|
||||||
|
)
|
||||||
|
// count(after=t5) === list(..., after=t5, 0, +∞).size
|
||||||
|
val cutoff = t0 + kotlin.time.Duration.parse("PT5S")
|
||||||
|
assertEquals(
|
||||||
|
store.list("conv1", cutoff, 0, 1000).size.toLong(),
|
||||||
|
store.count("conv1", cutoff),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+146
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-62
@@ -1,26 +1,31 @@
|
|||||||
package pw.binom.agentik.storage.ksqlite
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.db.ksqlite.SQLiteConnection
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тесты на Schema.migrate():
|
* Тесты на Schema.migrate() в `:journal-ksqlite`:
|
||||||
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION;
|
* - fresh DB → создаются `conversation` + `message` + индексы
|
||||||
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на
|
* (`idx_conv_updated`, `idx_msg_conv`);
|
||||||
* повторных CREATE);
|
* - уже мигрированная БД → migrate() идемпотентен (no-op);
|
||||||
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores),
|
* - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
|
||||||
* migrate() приводит её в боевое состояние.
|
* migrate() приводит её в боевое состояние;
|
||||||
|
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
|
||||||
|
* делают full-scan по message.
|
||||||
*
|
*
|
||||||
* Также проверяем что наличие индекса idx_msg_conv (conversation_id +
|
* `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
|
||||||
* created_at) — обязательный hot-path для list()/cascade-delete.
|
* в `pw.binom.agentik.reflection.ksqlite`.
|
||||||
*/
|
*/
|
||||||
class SchemaMigrationTest {
|
class SchemaMigrationTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest {
|
fun `fresh DB gets conversation and message tables and indexes`() = runTest {
|
||||||
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
|
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
|
||||||
try {
|
try {
|
||||||
Schema.migrate(conn)
|
Schema.migrate(conn)
|
||||||
@@ -28,8 +33,6 @@ class SchemaMigrationTest {
|
|||||||
for (table in listOf(
|
for (table in listOf(
|
||||||
Schema.TABLE_CONVERSATION,
|
Schema.TABLE_CONVERSATION,
|
||||||
Schema.TABLE_MESSAGE,
|
Schema.TABLE_MESSAGE,
|
||||||
Schema.TABLE_WORKING_MEMORY,
|
|
||||||
Schema.TABLE_REFLECTION,
|
|
||||||
)) {
|
)) {
|
||||||
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
|
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
|
||||||
}
|
}
|
||||||
@@ -37,15 +40,9 @@ class SchemaMigrationTest {
|
|||||||
for (index in listOf(
|
for (index in listOf(
|
||||||
Schema.IDX_CONV_UPDATED,
|
Schema.IDX_CONV_UPDATED,
|
||||||
Schema.IDX_MSG_CONV,
|
Schema.IDX_MSG_CONV,
|
||||||
Schema.IDX_WM_UNIQUE,
|
|
||||||
Schema.IDX_WM_CONV,
|
|
||||||
Schema.IDX_REFLECTION_CREATED,
|
|
||||||
Schema.IDX_REFLECTION_CONV,
|
|
||||||
)) {
|
)) {
|
||||||
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
|
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
|
|
||||||
} finally {
|
} finally {
|
||||||
conn.close()
|
conn.close()
|
||||||
}
|
}
|
||||||
@@ -56,66 +53,50 @@ class SchemaMigrationTest {
|
|||||||
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
|
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
|
||||||
try {
|
try {
|
||||||
Schema.migrate(conn)
|
Schema.migrate(conn)
|
||||||
val versionAfterFirst = readUserVersion(conn)
|
// повторный вызов не должен ни упасть, ни пересоздать таблицы
|
||||||
|
// (CREATE IF NOT EXISTS — no-op)
|
||||||
// повторный вызов не должен ни упасть, ни изменить версию, ни
|
|
||||||
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
|
|
||||||
Schema.migrate(conn)
|
Schema.migrate(conn)
|
||||||
assertEquals(versionAfterFirst, readUserVersion(conn))
|
Schema.migrate(conn)
|
||||||
|
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
|
||||||
|
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
|
||||||
} finally {
|
} finally {
|
||||||
conn.close()
|
conn.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest {
|
fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
|
||||||
// Имитируем сценарий: существующая БД без schema, открываем через
|
|
||||||
// ksqlite и прогоняем migrate руками (тот же путь, что в
|
|
||||||
// KsqliteStores.open, но без зависимости от фабрики).
|
|
||||||
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
|
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
|
||||||
Schema.migrate(conn)
|
Schema.migrate(conn)
|
||||||
|
|
||||||
// Сборка bundle через internal-конструктор — KsqliteStores primary
|
val convStore = KsqliteMutableConversationStore(conn)
|
||||||
// constructor internal, тест в том же модуле и может его звать.
|
val msgStore = KsqliteJournalStore(conn)
|
||||||
val stores = KsqliteStores(
|
|
||||||
connection = conn,
|
|
||||||
conversations = KsqliteMutableConversationStore(conn),
|
|
||||||
messages = KsqliteMessageStore(conn),
|
|
||||||
workingMemory = KsqliteWorkingMemoryStore(conn),
|
|
||||||
reflections = KsqliteReflectionStore(conn),
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
// bundle работает end-to-end — conversation upsert + message append +
|
convStore.upsert(
|
||||||
// list. Никаких "no such table" или подобного.
|
ConversationRecord(
|
||||||
stores.conversations.upsert(
|
|
||||||
pw.binom.agentik.journal.ConversationRecord(
|
|
||||||
id = "c1", title = "t", isTemporal = false,
|
id = "c1", title = "t", isTemporal = false,
|
||||||
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
|
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
|
||||||
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
|
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
stores.messages.append(
|
msgStore.append(
|
||||||
pw.binom.agentik.journal.MessageRecord.UserMessage(
|
MessageRecord.UserMessage(
|
||||||
id = "m1", conversationId = "c1",
|
id = "m1", conversationId = "c1",
|
||||||
content = listOf(pw.binom.agentik.journal.Content.Text("hi")),
|
content = listOf(Content.Text("hi")),
|
||||||
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
|
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
|
val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
|
||||||
assertEquals(1, got.size)
|
assertEquals(1, got.size)
|
||||||
assertEquals("m1", got[0].id)
|
assertEquals("m1", got[0].id)
|
||||||
} finally {
|
} finally {
|
||||||
// Закрываем store'ы → они закроют свои pre-prepared statements
|
convStore.close()
|
||||||
// (StmtHolder.finalize увидит isOpen == false и не полезет в
|
msgStore.close()
|
||||||
// нативный sqlite3_finalize с уже-разрушенным db mutex).
|
|
||||||
stores.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
|
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
|
||||||
// Проверяем что индекс действительно покрывает обе колонки — без
|
|
||||||
// этого list()/cascade-delete будут делать full-scan по message.
|
|
||||||
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
|
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
|
||||||
try {
|
try {
|
||||||
Schema.migrate(conn)
|
Schema.migrate(conn)
|
||||||
@@ -158,16 +139,4 @@ class SchemaMigrationTest {
|
|||||||
}
|
}
|
||||||
return cols
|
return cols
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readUserVersion(conn: SQLiteConnection): Int {
|
|
||||||
var version = 0
|
|
||||||
conn.prepare("PRAGMA user_version").use { stmt ->
|
|
||||||
stmt.executeQuery().use { rs ->
|
|
||||||
if (rs.next()) {
|
|
||||||
version = (rs.getLong(0) ?: 0L).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return version
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||||
//
|
//
|
||||||
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
|
// KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
|
||||||
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
|
// `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
|
||||||
// доступные JVM target'у.
|
|
||||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
@@ -15,6 +14,14 @@ kotlin {
|
|||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
jvm()
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
@@ -24,16 +31,18 @@ kotlin {
|
|||||||
api(project(":context-api"))
|
api(project(":context-api"))
|
||||||
api(project(":skills"))
|
api(project(":skills"))
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
|
||||||
|
// jvm/native variant из общего артефакта).
|
||||||
|
implementation(libs.kotlin.logging)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
}
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
jvmMain.dependencies {
|
implementation(libs.litert.tools.kotlinx.serialization)
|
||||||
// mu.KotlinLogging — JVM-only, для SkillMiner'а
|
implementation(project(":memory-md"))
|
||||||
implementation(libs.kotlin.logging)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class LlmReflector(
|
|||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
val maxTurns: Int = 6,
|
val maxTurns: Int = 6,
|
||||||
private val maxTokens: Int = 512,
|
private val maxTokens: Int = 512,
|
||||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||||
private val clock: Clock = Clock.System,
|
private val clock: Clock = Clock.System,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package pw.binom.agentik.llm.tools
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import pw.binom.litert.LiteContentPart
|
||||||
|
import pw.binom.litert.LiteConversation
|
||||||
|
import pw.binom.litert.LiteConversationConfig
|
||||||
|
import pw.binom.litert.LiteDelta
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.LiteMessage
|
||||||
|
import pw.binom.litert.LiteRole
|
||||||
|
import pw.binom.litert.LiteToolCall
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
|
||||||
|
* заданной строкой [reply] двумя фрагментами + done.
|
||||||
|
*/
|
||||||
|
internal class FakeLiteLlm : LiteLlm {
|
||||||
|
sealed class Reply {
|
||||||
|
data class Text(val text: String) : Reply()
|
||||||
|
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override val backendName: String = "fake"
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||||
|
var reply: String = ""
|
||||||
|
var rememberHistory: Boolean = false
|
||||||
|
var slow: Boolean = false
|
||||||
|
var failMessage: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Если задан, LLM проходит по этому списку ответов по порядку: первый
|
||||||
|
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
|
||||||
|
* кончился — fallback на [reply] (text).
|
||||||
|
*/
|
||||||
|
var scriptedReplies: MutableList<Reply> = mutableListOf()
|
||||||
|
|
||||||
|
var lastConfig: LiteConversationConfig? = null
|
||||||
|
var lastContents: List<LiteContentPart>? = null
|
||||||
|
val conversations = mutableListOf<FakeLiteConversation>()
|
||||||
|
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
|
||||||
|
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
||||||
|
lastConfig = config
|
||||||
|
val conv = FakeLiteConversation(this, config)
|
||||||
|
conversations.add(conv)
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String =
|
||||||
|
throw UnsupportedOperationException("not used in test")
|
||||||
|
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
|
||||||
|
throw UnsupportedOperationException("not used in test")
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
|
||||||
|
fun nextReply(): Reply =
|
||||||
|
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class FakeLiteConversation(
|
||||||
|
private val parent: FakeLiteLlm,
|
||||||
|
config: LiteConversationConfig,
|
||||||
|
) : LiteConversation {
|
||||||
|
val initialMessages: List<LiteMessage> = config.initialMessages
|
||||||
|
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
||||||
|
override val history: List<LiteMessage> get() = mutableHistory.toList()
|
||||||
|
override var systemInstruction: String? = config.systemInstruction
|
||||||
|
override var tools: List<pw.binom.litert.LiteTool> = config.tools
|
||||||
|
|
||||||
|
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
||||||
|
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||||
|
|
||||||
|
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||||
|
parent.lastContents = contents
|
||||||
|
parent.failMessage?.let { msg ->
|
||||||
|
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
|
||||||
|
}
|
||||||
|
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
||||||
|
val next = parent.nextReply()
|
||||||
|
return when (next) {
|
||||||
|
is FakeLiteLlm.Reply.Text -> {
|
||||||
|
if (parent.slow) {
|
||||||
|
kotlinx.coroutines.flow.flow {
|
||||||
|
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
|
||||||
|
kotlinx.coroutines.delay(10_000)
|
||||||
|
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
|
||||||
|
mutableHistory.add(LiteMessage.model(next.text))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val first = next.text.substring(0, next.text.length / 2)
|
||||||
|
val second = next.text.substring(next.text.length / 2)
|
||||||
|
flowOf(
|
||||||
|
LiteDelta(text = first),
|
||||||
|
LiteDelta(text = second, isDone = true),
|
||||||
|
).also { mutableHistory.add(LiteMessage.model(next.text)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FakeLiteLlm.Reply.ToolCalls -> {
|
||||||
|
val calls = next.calls.map { (name, args) ->
|
||||||
|
LiteToolCall(name = name, arguments = args)
|
||||||
|
}
|
||||||
|
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun replaceHistory(newHistory: List<LiteMessage>) {
|
||||||
|
mutableHistory.clear()
|
||||||
|
mutableHistory.addAll(newHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(prompt: String): String {
|
||||||
|
parent.lastContents = listOf(LiteContentPart.Text(prompt))
|
||||||
|
return parent.reply
|
||||||
|
}
|
||||||
|
override fun sendContents(contents: List<LiteContentPart>): String {
|
||||||
|
parent.lastContents = contents
|
||||||
|
return parent.reply
|
||||||
|
}
|
||||||
|
override fun cancel() {}
|
||||||
|
override fun tokenCount(): Int = history.size
|
||||||
|
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
|
||||||
|
LiteDelta(text = "", isDone = true)
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.llm.tools
|
||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent.memory
|
package pw.binom.agentik.llm.tools.memory
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -8,7 +8,7 @@ import pw.binom.agentik.memory.MemorySource
|
|||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
import pw.binom.agentik.standalone.agent.FakeLiteLlm
|
import pw.binom.agentik.llm.tools.FakeLiteLlm
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent.memory
|
package pw.binom.agentik.llm.tools.memory
|
||||||
|
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
@@ -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) — пока нет.
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
// или :agentik-tui когда те снова включатся.
|
// или :agentik-tui когда те снова включатся.
|
||||||
//
|
//
|
||||||
// Зависимости:
|
// Зависимости:
|
||||||
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
|
// - litert.api для LiteTool контракта (в v9 у LiteTool появилось поле name,
|
||||||
// - litert.api для LiteTool контракта
|
// NamedTool-обёртка из :agent-toolsets больше не нужна)
|
||||||
// - MCP SDK (JVM-only)
|
// - MCP SDK (JVM-only)
|
||||||
// - Ktor client (для StreamableHttpClientTransport)
|
// - Ktor client (для StreamableHttpClientTransport)
|
||||||
// - kotlinx-serialization для парсинга конфига
|
// - kotlinx-serialization для парсинга конфига
|
||||||
@@ -22,7 +22,9 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":agent-toolsets"))
|
// :agent-api — отсюда Component / ToolProvider; McpBridgeComponent
|
||||||
|
// реализует Component и подсовывает MCP-тулы через ToolProvider.
|
||||||
|
api(project(":agent-api"))
|
||||||
|
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package pw.binom.agentik.mcp.bridge
|
||||||
|
|
||||||
|
import pw.binom.agentik.agent.Component
|
||||||
|
import pw.binom.agentik.agent.MutableAgent
|
||||||
|
import pw.binom.agentik.agent.ToolProvider
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Component], встраивающий [McpRegistry] в [MutableAgent] через [ToolProvider].
|
||||||
|
*
|
||||||
|
* При [install] добавляет один [ToolProvider] в `agent.toolProviders` —
|
||||||
|
* он возвращает `registry.allTools` (все MCP-тулы со всех подключённых
|
||||||
|
* серверов, с префиксом `serverName__` чтобы избежать коллизий).
|
||||||
|
*
|
||||||
|
* При [uninstall] убирает свой [ToolProvider] обратно. Повторный `uninstall`
|
||||||
|
* — no-op. **Не** закрывает [McpRegistry] — за это отвечает host
|
||||||
|
* (обычно shutdown hook в `Main.kt`).
|
||||||
|
*
|
||||||
|
* Типичное использование:
|
||||||
|
* ```
|
||||||
|
* val registry = McpRegistry.fromConfig(config.mcp)
|
||||||
|
* val agent = ChatAgent(...).install(McpBridgeComponent(registry))
|
||||||
|
* // ...
|
||||||
|
* Runtime.getRuntime().addShutdownHook(Thread { registry.close() })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Пока встраивается только в `:standalone` через `:mcp-bridge` —
|
||||||
|
* `:agentik-cli` / `:agentik-tui` (когда снова включатся) получат эту же
|
||||||
|
* механику без изменений в [ChatAgent] constructor'е.
|
||||||
|
*/
|
||||||
|
class McpBridgeComponent(
|
||||||
|
registry: McpRegistry,
|
||||||
|
) : Component {
|
||||||
|
|
||||||
|
private val provider = McpToolProvider(registry)
|
||||||
|
|
||||||
|
override fun install(agent: MutableAgent) {
|
||||||
|
if (provider !in agent.toolProviders)
|
||||||
|
agent.toolProviders += provider
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uninstall(agent: MutableAgent) {
|
||||||
|
agent.toolProviders -= provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [ToolProvider] поверх [McpRegistry.allTools]: всегда отдаёт
|
||||||
|
* полный список MCP-тулов вне зависимости от `conversationId`
|
||||||
|
* (per-conversation фильтрация для MCP будет, если/когда понадобится —
|
||||||
|
* сейчас MCP-тулы глобальны и для всех бесед одинаковы).
|
||||||
|
*/
|
||||||
|
private class McpToolProvider(
|
||||||
|
private val registry: McpRegistry,
|
||||||
|
) : ToolProvider {
|
||||||
|
|
||||||
|
override fun getTools(conversationId: String): List<LiteTool> = registry.allTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val McpRegistry.component
|
||||||
|
get() = McpBridgeComponent(this)
|
||||||
@@ -32,7 +32,6 @@ import kotlinx.serialization.json.longOrNull
|
|||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реестр подключённых MCP-серверов.
|
* Реестр подключённых MCP-серверов.
|
||||||
@@ -60,11 +59,6 @@ class McpRegistry(
|
|||||||
connected.values.flatMap { it.tools }
|
connected.values.flatMap { it.tools }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
|
|
||||||
val namedTools: List<NamedTool> by lazy {
|
|
||||||
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Количество успешно подключённых серверов. */
|
/** Количество успешно подключённых серверов. */
|
||||||
val connectedServerCount: Int get() = connected.size
|
val connectedServerCount: Int get() = connected.size
|
||||||
|
|
||||||
@@ -181,13 +175,13 @@ internal class McpLiteToolAdapter(
|
|||||||
private val client: Client,
|
private val client: Client,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
|
|
||||||
internal val fullName: String = "${serverName}__${tool.name}"
|
override val name: String = "${serverName}__${tool.name}"
|
||||||
|
|
||||||
override fun describe(): String =
|
override fun describe(): String =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
|
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
|
||||||
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
|
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
|
||||||
put("name", fullName)
|
put("name", name)
|
||||||
put("description", tool.description ?: "")
|
put("description", tool.description ?: "")
|
||||||
put("parameters", tool.inputSchema.toJsonSchema())
|
put("parameters", tool.inputSchema.toJsonSchema())
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|||||||
@@ -106,4 +106,60 @@ sealed interface Event {
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("error")
|
@SerialName("error")
|
||||||
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
|
||||||
|
* kernel прибил таск). Отличается от [ToolResult]: там мы сообщаем LLM
|
||||||
|
* результат (даже если LLM его не понравился), тут — сигнал о
|
||||||
|
* внутренней ошибке **самого исполнения тула**. Используется
|
||||||
|
* background-подписчиками (например [ReflectionScheduler]-like
|
||||||
|
* компонентами) для накопления паттернов отказов. Клиенту
|
||||||
|
* показывается для transparency, но в UI особо не нужен.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_failed")
|
||||||
|
data class ToolFailed(
|
||||||
|
override val date: Instant,
|
||||||
|
val toolCallId: String,
|
||||||
|
val toolName: String?,
|
||||||
|
val message: String,
|
||||||
|
val durationMs: Long,
|
||||||
|
) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диалог переходит в закрытое состояние ([Conversation.close] /
|
||||||
|
* [ConversationLoop.close] / `agent.deleteConversation`). Эмитится
|
||||||
|
* **до** освобождения ресурсов, чтобы background-подписчики
|
||||||
|
* (skill mining, reflection) успели сделать final pass. После
|
||||||
|
* `Closed` диалог уже удалён из `agent.getConversations()` и
|
||||||
|
* `getMessages()` отдаст только то, что осталось в журнале.
|
||||||
|
*
|
||||||
|
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
|
||||||
|
* так как открытие тривиально (id уже известен с момента
|
||||||
|
* `Agent.createConversation` → [Event.ConversationCreated]
|
||||||
|
* / [AgentEvent.Created] в outbox'е).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("conversation_closing")
|
||||||
|
data class ConversationClosing(
|
||||||
|
override val date: Instant,
|
||||||
|
val conversationId: String,
|
||||||
|
) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
|
||||||
|
* Эмитится **до** deletion для background-подписчиков (skill mining),
|
||||||
|
* чтобы они успели сделать pass на исчезающем контенте.
|
||||||
|
*
|
||||||
|
* В отличие от [ToolFailed]/[ConversationClosing], это событие
|
||||||
|
* семантически "много контента ушло" — subscribers могут решать,
|
||||||
|
* стоит ли тратить tokens на mining ([turnsCompacted] > N).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("compaction_triggered")
|
||||||
|
data class CompactionTriggered(
|
||||||
|
override val date: Instant,
|
||||||
|
val conversationId: String,
|
||||||
|
val turnsCompacted: Int,
|
||||||
|
) : Event
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ data class Reflection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу (impl в
|
* Хранилище рефлексий. Реализации:
|
||||||
* `:storage-sqlite`) или in-memory (impl в `:storage-inmemory`).
|
* - `:reflection-ksqlite` — таблица `reflection` поверх ksqlite
|
||||||
|
* - `:reflection-inmemory` — `Map<String, Reflection>` для тестов / dev
|
||||||
*
|
*
|
||||||
* Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup)
|
* Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup)
|
||||||
* или архивируются через [Curator]-подобный процесс, но не редактируются.
|
* или архивируются через [Curator]-подобный процесс, но не редактируются.
|
||||||
@@ -33,8 +34,10 @@ data class Reflection(
|
|||||||
interface ReflectionStore : AutoCloseable {
|
interface ReflectionStore : AutoCloseable {
|
||||||
suspend fun insert(reflection: Reflection)
|
suspend fun insert(reflection: Reflection)
|
||||||
suspend fun get(id: String): Reflection?
|
suspend fun get(id: String): Reflection?
|
||||||
|
|
||||||
/** Самые свежие рефлексии (по всему агенту). */
|
/** Самые свежие рефлексии (по всему агенту). */
|
||||||
suspend fun listRecent(limit: Int = 10): List<Reflection>
|
suspend fun listRecent(limit: Int = 10): List<Reflection>
|
||||||
|
|
||||||
/** Рефлексии для конкретного диалога. */
|
/** Рефлексии для конкретного диалога. */
|
||||||
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
|
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
|
||||||
suspend fun deleteOlderThan(cutoff: Instant)
|
suspend fun deleteOlderThan(cutoff: Instant)
|
||||||
@@ -55,5 +58,5 @@ sealed interface ReflectionEvent {
|
|||||||
* БД-схемах было видно сразу.
|
* БД-схемах было видно сразу.
|
||||||
*/
|
*/
|
||||||
object Ids {
|
object Ids {
|
||||||
fun new(): String = "refl-${kotlin.uuid.Uuid.random()}"
|
fun new() = "refl-${kotlin.uuid.Uuid.random()}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage.inmemory
|
package pw.binom.agentik.reflection.inmemory
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -11,7 +11,7 @@ import pw.binom.agentik.reflection.ReflectionStore
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread-safe List-импл [ReflectionStore].
|
* Thread-safe Map-импл [ReflectionStore].
|
||||||
*
|
*
|
||||||
* `weakSpots` хранятся как List<String> в самой структуре — JSON-сериализация
|
* `weakSpots` хранятся как List<String> в самой структуре — JSON-сериализация
|
||||||
* делается на уровне SQLDelight в SQLite-импле; здесь просто держим в памяти.
|
* делается на уровне SQLDelight в SQLite-импле; здесь просто держим в памяти.
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
package pw.binom.agentik.reflection.inmemory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.reflection.Reflection
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class InMemoryReflectionStoreTest {
|
||||||
|
|
||||||
|
private fun refl(
|
||||||
|
id: String,
|
||||||
|
conv: String? = null,
|
||||||
|
ts: Instant = Instant.parse("2026-09-15T10:00:00Z"),
|
||||||
|
score: Int = 3,
|
||||||
|
summary: String = "ok",
|
||||||
|
weak: List<String> = listOf("weak1", "weak2"),
|
||||||
|
) = Reflection(
|
||||||
|
id = id,
|
||||||
|
conversationId = conv,
|
||||||
|
createdAt = ts,
|
||||||
|
turnsAnalyzed = 5,
|
||||||
|
score = score,
|
||||||
|
summary = summary,
|
||||||
|
weakSpots = weak,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testInsertAndGetRoundtrip() = runTest {
|
||||||
|
val store = InMemoryReflectionStore()
|
||||||
|
store.insert(refl("r1"))
|
||||||
|
val got = store.get("r1")
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals("r1", got.id)
|
||||||
|
assertEquals(listOf("weak1", "weak2"), got.weakSpots)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetReturnsNullForMissing() = runTest {
|
||||||
|
val store = InMemoryReflectionStore()
|
||||||
|
assertNull(store.get("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListRecentSortedByCreatedAtDesc() = runTest {
|
||||||
|
val store = InMemoryReflectionStore()
|
||||||
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.insert(refl("r1", ts = t0))
|
||||||
|
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S")))
|
||||||
|
store.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S")))
|
||||||
|
|
||||||
|
val recent = store.listRecent(limit = 3)
|
||||||
|
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListForConversationFilters() = runTest {
|
||||||
|
val store = InMemoryReflectionStore()
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.insert(refl("r1", conv = "c-1"))
|
||||||
|
store.insert(refl("r2", conv = "c-2"))
|
||||||
|
store.insert(refl("r3", conv = "c-1"))
|
||||||
|
val forC1 = store.listForConversation("c-1", limit = 10)
|
||||||
|
assertEquals(2, forC1.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeleteOlderThanRemoves() = runTest {
|
||||||
|
val store = InMemoryReflectionStore()
|
||||||
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.insert(refl("r1", ts = t0))
|
||||||
|
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
|
||||||
|
store.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
|
||||||
|
assertNull(store.get("r1"))
|
||||||
|
assertNotNull(store.get("r2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCountReturnsTotal() = runTest {
|
||||||
|
val store = InMemoryReflectionStore()
|
||||||
|
assertEquals(0, store.count())
|
||||||
|
store.insert(refl("r1"))
|
||||||
|
store.insert(refl("r2"))
|
||||||
|
assertEquals(2, store.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCloseIdempotent() = runTest {
|
||||||
|
val store = InMemoryReflectionStore()
|
||||||
|
store.close()
|
||||||
|
store.close() // no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-3
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage.ksqlite
|
package pw.binom.agentik.reflection.ksqlite
|
||||||
|
|
||||||
import pw.binom.agentik.reflection.Reflection
|
import pw.binom.agentik.reflection.Reflection
|
||||||
import pw.binom.agentik.reflection.ReflectionEvent
|
import pw.binom.agentik.reflection.ReflectionEvent
|
||||||
@@ -16,11 +16,42 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class KsqliteReflectionStore(
|
/**
|
||||||
|
* ksqlite-реализация [ReflectionStore].
|
||||||
|
*
|
||||||
|
* ## Lifecycle соединения
|
||||||
|
*
|
||||||
|
* Семантика владения connection'ом идентична
|
||||||
|
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
|
||||||
|
* - `KsqliteReflectionStore(connection)` — внешнее соединение, store НЕ
|
||||||
|
* закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle).
|
||||||
|
* - `KsqliteReflectionStore(path)` — открывает файловое соединение,
|
||||||
|
* прогоняет [Schema.migrate], закрывает соединение в [close].
|
||||||
|
* - `KsqliteReflectionStore.memory(name)` — in-memory, мигрирует,
|
||||||
|
* закрывает в [close].
|
||||||
|
*/
|
||||||
|
class KsqliteReflectionStore private constructor(
|
||||||
private val connection: SQLiteConnection,
|
private val connection: SQLiteConnection,
|
||||||
|
private val ownsConnection: Boolean,
|
||||||
private val clock: Clock = Clock.System,
|
private val clock: Clock = Clock.System,
|
||||||
) : ReflectionStore {
|
) : ReflectionStore {
|
||||||
|
|
||||||
|
constructor(path: String, clock: Clock = Clock.System) : this(
|
||||||
|
connection = SQLiteConnection.open(path = path),
|
||||||
|
ownsConnection = true,
|
||||||
|
clock = clock,
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(connection: SQLiteConnection, clock: Clock = Clock.System) : this(
|
||||||
|
connection = connection,
|
||||||
|
ownsConnection = false,
|
||||||
|
clock = clock,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Schema.migrate(connection)
|
||||||
|
}
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16)
|
private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16)
|
||||||
|
|
||||||
@@ -155,6 +186,16 @@ class KsqliteReflectionStore(
|
|||||||
listForConvStmt.close()
|
listForConvStmt.close()
|
||||||
deleteOlderThanStmt.close()
|
deleteOlderThanStmt.close()
|
||||||
countStmt.close()
|
countStmt.close()
|
||||||
|
if (ownsConnection) connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun memory(name: String? = null, clock: Clock = Clock.System): KsqliteReflectionStore =
|
||||||
|
KsqliteReflectionStore(
|
||||||
|
connection = SQLiteConnection.memory(name),
|
||||||
|
ownsConnection = true,
|
||||||
|
clock = clock,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SQLiteResultSet.toDomain(): Reflection = Reflection(
|
private fun SQLiteResultSet.toDomain(): Reflection = Reflection(
|
||||||
@@ -168,7 +209,7 @@ class KsqliteReflectionStore(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hand-rolled JSON-encode/decode для List<String> — синхронизировать с :storage-sqlite. */
|
/** Hand-rolled JSON-encode/decode для List<String>. */
|
||||||
internal fun encodeStringArray(items: List<String>): String = buildString {
|
internal fun encodeStringArray(items: List<String>): String = buildString {
|
||||||
append('[')
|
append('[')
|
||||||
items.forEachIndexed { i, s ->
|
items.forEachIndexed { i, s ->
|
||||||
+71
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-25
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage.ksqlite
|
package pw.binom.agentik.reflection.ksqlite
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.reflection.Reflection
|
import pw.binom.agentik.reflection.Reflection
|
||||||
@@ -8,19 +8,18 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class KsqliteReflectionStoreTest {
|
class KsqliteReflectionStoreTest {
|
||||||
private lateinit var stores: KsqliteStores
|
private lateinit var store: KsqliteReflectionStore
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
stores = KsqliteStores.inMemory("refl-${kotlin.random.Random.nextLong()}")
|
store = KsqliteReflectionStore.memory("refl-${kotlin.random.Random.nextLong()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterTest
|
@AfterTest
|
||||||
fun tearDown() = stores.close()
|
fun tearDown() = store.close()
|
||||||
|
|
||||||
private fun refl(
|
private fun refl(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -41,8 +40,8 @@ class KsqliteReflectionStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInsertAndGetRoundtrip() = runTest {
|
fun testInsertAndGetRoundtrip() = runTest {
|
||||||
stores.reflections.insert(refl("r1"))
|
store.insert(refl("r1"))
|
||||||
val got = stores.reflections.get("r1")
|
val got = store.get("r1")
|
||||||
assertNotNull(got)
|
assertNotNull(got)
|
||||||
assertEquals("r1", got.id)
|
assertEquals("r1", got.id)
|
||||||
assertEquals(listOf("weak1", "weak2"), got.weakSpots)
|
assertEquals(listOf("weak1", "weak2"), got.weakSpots)
|
||||||
@@ -50,45 +49,45 @@ class KsqliteReflectionStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testGetReturnsNullForMissing() = runTest {
|
fun testGetReturnsNullForMissing() = runTest {
|
||||||
assertNull(stores.reflections.get("nope"))
|
assertNull(store.get("nope"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testListRecentSortedByCreatedAtDesc() = runTest {
|
fun testListRecentSortedByCreatedAtDesc() = runTest {
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
stores.reflections.insert(refl("r1", ts = t0))
|
store.insert(refl("r1", ts = t0))
|
||||||
stores.reflections.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S")))
|
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S")))
|
||||||
stores.reflections.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S")))
|
store.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S")))
|
||||||
|
|
||||||
val recent = stores.reflections.listRecent(limit = 3)
|
val recent = store.listRecent(limit = 3)
|
||||||
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
|
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testListForConversationFilters() = runTest {
|
fun testListForConversationFilters() = runTest {
|
||||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
stores.reflections.insert(refl("r1", conv = "c-1"))
|
store.insert(refl("r1", conv = "c-1"))
|
||||||
stores.reflections.insert(refl("r2", conv = "c-2"))
|
store.insert(refl("r2", conv = "c-2"))
|
||||||
stores.reflections.insert(refl("r3", conv = "c-1"))
|
store.insert(refl("r3", conv = "c-1"))
|
||||||
val forC1 = stores.reflections.listForConversation("c-1", limit = 10)
|
val forC1 = store.listForConversation("c-1", limit = 10)
|
||||||
assertEquals(2, forC1.size)
|
assertEquals(2, forC1.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDeleteOlderThanRemoves() = runTest {
|
fun testDeleteOlderThanRemoves() = runTest {
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
stores.reflections.insert(refl("r1", ts = t0))
|
store.insert(refl("r1", ts = t0))
|
||||||
stores.reflections.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
|
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
|
||||||
stores.reflections.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
|
store.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
|
||||||
assertNull(stores.reflections.get("r1"))
|
assertNull(store.get("r1"))
|
||||||
assertNotNull(stores.reflections.get("r2"))
|
assertNotNull(store.get("r2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testCountReturnsTotal() = runTest {
|
fun testCountReturnsTotal() = runTest {
|
||||||
assertEquals(0, stores.reflections.count())
|
assertEquals(0, store.count())
|
||||||
stores.reflections.insert(refl("r1"))
|
store.insert(refl("r1"))
|
||||||
stores.reflections.insert(refl("r2"))
|
store.insert(refl("r2"))
|
||||||
assertEquals(2, stores.reflections.count())
|
assertEquals(2, store.count())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package pw.binom.agentik.reflection.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты на Schema.migrate() в `:reflection-ksqlite`:
|
||||||
|
* - fresh DB → создаются `reflection` + индексы;
|
||||||
|
* - уже мигрированная БД → migrate() идемпотентен (no-op).
|
||||||
|
*/
|
||||||
|
class SchemaMigrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fresh DB gets reflection table and indexes`() = runTest {
|
||||||
|
val conn = SQLiteConnection.memory("refl-mig-fresh-${kotlin.random.Random.nextLong()}")
|
||||||
|
try {
|
||||||
|
Schema.migrate(conn)
|
||||||
|
|
||||||
|
assertTrue(tableExists(conn, Schema.TABLE_REFLECTION), "table '${Schema.TABLE_REFLECTION}' should exist after migrate()")
|
||||||
|
for (index in listOf(
|
||||||
|
Schema.IDX_REFLECTION_CREATED,
|
||||||
|
Schema.IDX_REFLECTION_CONV,
|
||||||
|
)) {
|
||||||
|
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migrate is idempotent on already-migrated DB`() = runTest {
|
||||||
|
val conn = SQLiteConnection.memory("refl-mig-idem-${kotlin.random.Random.nextLong()}")
|
||||||
|
try {
|
||||||
|
Schema.migrate(conn)
|
||||||
|
// повторный вызов не должен ни упасть, ни пересоздать таблицы
|
||||||
|
Schema.migrate(conn)
|
||||||
|
Schema.migrate(conn)
|
||||||
|
assertTrue(tableExists(conn, Schema.TABLE_REFLECTION))
|
||||||
|
} finally {
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableExists(conn: SQLiteConnection, name: String): Boolean {
|
||||||
|
conn.prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"
|
||||||
|
).use { stmt ->
|
||||||
|
stmt.bindText(1, name)
|
||||||
|
stmt.executeQuery().use { rs -> return rs.next() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun indexExists(conn: SQLiteConnection, name: String): Boolean {
|
||||||
|
conn.prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?"
|
||||||
|
).use { stmt ->
|
||||||
|
stmt.bindText(1, name)
|
||||||
|
stmt.executeQuery().use { rs -> return rs.next() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-24
@@ -34,7 +34,7 @@ include(":standalone")
|
|||||||
include(":llm-tools")
|
include(":llm-tools")
|
||||||
// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены
|
// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены
|
||||||
// из :standalone — MCP не специфичен для standalone'а, это generic мост
|
// из :standalone — MCP не специфичен для standalone'а, это generic мост
|
||||||
// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool).
|
// между MCP-SDK и LiteTool.
|
||||||
include(":mcp-bridge")
|
include(":mcp-bridge")
|
||||||
// Собственный протокол agentik. Пока в нём пилим, потом вынесем.
|
// Собственный протокол agentik. Пока в нём пилим, потом вынесем.
|
||||||
include(":proto")
|
include(":proto")
|
||||||
@@ -61,18 +61,15 @@ include(":memory-md")
|
|||||||
// для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART
|
// для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART
|
||||||
// работает через Java 11 base classes (scalar fallback).
|
// работает через Java 11 base classes (scalar fallback).
|
||||||
include(":memory-vector")
|
include(":memory-vector")
|
||||||
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore /
|
// Интерфейсы хранилища и разговорной истории. Контракты распределены по
|
||||||
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации —
|
// доменам: :journal-api (MutableJournalStore/MutableConversationStore),
|
||||||
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred).
|
// :context-api (ContextStore), :reflection-api (ReflectionStore).
|
||||||
// In-memory реализация store'ов из :journal-api и :context-api. KMP,
|
// Реализации — :journal-ksqlite/:context-ksqlite/:reflection-ksqlite (ksqlite)
|
||||||
// IO-зависимостей. Используется в тестах (быстрый setup, без JDBC) и будет
|
// и :journal-inmemory/:reflection-inmemory (in-memory).
|
||||||
// использоваться в Android-сборке (JVector/SQLite не подходят для ART out-of-box).
|
|
||||||
//include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids)
|
//include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids)
|
||||||
// и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids)
|
// и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids)
|
||||||
// Модуль удалён с диска.
|
// Модуль удалён с диска.
|
||||||
include(":reflection-api")
|
include(":reflection-api")
|
||||||
//include(":message-log-api")
|
|
||||||
//include(":message-log-ksqlite")
|
|
||||||
// Новые API-модули трёх сущностей (canonical имена):
|
// Новые API-модули трёх сущностей (canonical имена):
|
||||||
// - :journal-api — append-only audit log (бывший :message-log-api)
|
// - :journal-api — append-only audit log (бывший :message-log-api)
|
||||||
// - :context-api — то что видит LLM (бывший :working-memory-api)
|
// - :context-api — то что видит LLM (бывший :working-memory-api)
|
||||||
@@ -97,7 +94,6 @@ include(":journal-inmemory")
|
|||||||
include(":memory-md-vector")
|
include(":memory-md-vector")
|
||||||
//include(":event-store-in-memory")
|
//include(":event-store-in-memory")
|
||||||
//include(":working-memory-api")
|
//include(":working-memory-api")
|
||||||
include(":storage-inmemory")
|
|
||||||
// SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only
|
// SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only
|
||||||
// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
|
// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
|
||||||
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
|
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
|
||||||
@@ -105,25 +101,42 @@ include(":storage-inmemory")
|
|||||||
// Linux — собираются локально на macOS. Пока покрывает только EventStore;
|
// Linux — собираются локально на macOS. Пока покрывает только EventStore;
|
||||||
// остальные store'ы мигрируют после стабилизации ksqlite и реального
|
// остальные store'ы мигрируют после стабилизации ksqlite и реального
|
||||||
// использования на Android-агенте.
|
// использования на Android-агенте.
|
||||||
include(":storage-ksqlite")
|
|
||||||
// ksqlite-реализация :context-api (ContextStore / working_memory table).
|
// ksqlite-реализация :context-api (ContextStore / working_memory table).
|
||||||
// Минимальный модуль: только таблица `working_memory` + 2 индекса.
|
// Минимальный модуль: только таблица `working_memory` + 2 индекса.
|
||||||
// Параллельно существует :storage-ksqlite/KsqliteWorkingMemoryStore.kt —
|
|
||||||
// миграция consumers'ов по чуть-чуть, отдельно.
|
|
||||||
include(":context-ksqlite")
|
include(":context-ksqlite")
|
||||||
// ksqlite-реализация :journal-api (JournalStore / message table).
|
// ksqlite-реализация :journal-api (JournalStore / message table,
|
||||||
// Минимальный модуль: только таблица `message` + 1 индекс
|
// ConversationStore / conversation table). Минимальный модуль:
|
||||||
// `(conversation_id, created_at)`. Параллельно существует
|
// таблицы `message` + `conversation` + индексы.
|
||||||
// :storage-ksqlite/KsqliteMessageStore.kt — миграция consumers'ов
|
|
||||||
// по чуть-чуть, отдельно.
|
|
||||||
include(":journal-ksqlite")
|
include(":journal-ksqlite")
|
||||||
// KMP-реализация EventStore через ksqlite (https://github.com/caffeine-mgn/ksqlite).
|
// ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
|
||||||
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
|
// Минимальный модуль: только таблица `reflection` + 2 индекса.
|
||||||
// на KMP-таргетах (JVM + linuxX64 + mingwX64). Apple targets auto-disabled на
|
include(":reflection-ksqlite")
|
||||||
// Linux — собираются локально на macOS. На этом этапе покрывает только EventStore;
|
// in-memory реализация :reflection-api (ReflectionStore). Используется в
|
||||||
// остальные store'ы мигрируют после стабилизации ksqlite (>=0.2) и реального
|
// тестах, dev-режиме и embedded-сценариях. Чистый KMP (9 таргетов),
|
||||||
// использования на Android-агенте.
|
// никакого I/O.
|
||||||
|
include(":reflection-inmemory")
|
||||||
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
|
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
|
||||||
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
|
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
|
||||||
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
|
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
|
||||||
include(":agent-toolsets")
|
include(":agent-toolsets")
|
||||||
|
// API-модуль vector-index: интерфейсы ANN-индекса, общие для любых бэкендов
|
||||||
|
// (JVector, sqlite-vec, inmemory, ...). Pure KMP, без реализаций.
|
||||||
|
include(":vector-index-api")
|
||||||
|
// JVector-реализация `MutableVectorIndexStore`. JVM-only — JVector публикуется
|
||||||
|
// только под JVM (нет KMP-таргетов). In-RAM (без persistence в v1).
|
||||||
|
include(":vector-index-jvector")
|
||||||
|
// ksqlite-реализация `MutableVectorIndexStore`. KMP (все 9 целей) — brute-force
|
||||||
|
// ANN: SELECT всех записей + cosine similarity в Kotlin. Persistence — обычная
|
||||||
|
// SQLite БД. Подходит для small-to-medium масштабов; для больших — sqlite-vec
|
||||||
|
// или JVector (выше).
|
||||||
|
include(":vector-index-ksqlite")
|
||||||
|
// Расширяемый слой поверх :proto: `MutableAgent` (наследник Agent + install/uninstall
|
||||||
|
// компонентов), `Component` (install(MutableAgent)/uninstall(MutableAgent)) и три
|
||||||
|
// capability-провайдера (system-prompt, tools, event-listener). Каждый компонент
|
||||||
|
// сам регистрирует свои capability в MutableAgent при install и снимает их при
|
||||||
|
// uninstall. KMP, все 9 целей; чистый API-слой, без реализаций.
|
||||||
|
include(":agent-api")
|
||||||
|
// Skill-подсистема как компонент: тулы skill_save/skill_delete/skill_read +
|
||||||
|
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
|
||||||
|
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
|
||||||
|
include(":skill-mining")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.llm.tools
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -35,7 +35,7 @@ class SkillMiner(
|
|||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
val maxTurns: Int = 30,
|
val maxTurns: Int = 30,
|
||||||
private val maxTokens: Int = 1536,
|
private val maxTokens: Int = 1536,
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Mine по последним [turns] с учётом текущего каталога [existing].
|
* Mine по последним [turns] с учётом текущего каталога [existing].
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.agent.Component
|
||||||
|
import pw.binom.agentik.agent.ConversationAware
|
||||||
|
import pw.binom.agentik.agent.ConversationHandle
|
||||||
|
import pw.binom.agentik.agent.MutableAgent
|
||||||
|
import pw.binom.agentik.agent.SystemPromptProvider
|
||||||
|
import pw.binom.agentik.agent.ToolProvider
|
||||||
|
import pw.binom.agentik.skill.mining.SkillMiner
|
||||||
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
|
import pw.binom.agentik.skills.SkillStore
|
||||||
|
import pw.binom.agentik.skills.renderSystemPromptSection
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* События, по которым SkillMiningComponent решает, что пора майнить новые скилы.
|
||||||
|
*
|
||||||
|
* Standalone-часть мэпит свой [pw.binom.agentik.outbox.OutboxStore] (через
|
||||||
|
* [pw.binom.agentik.outbox.Event.ConversationClosing] и
|
||||||
|
* [pw.binom.agentik.outbox.Event.CompactionTriggered]) на этот sealed
|
||||||
|
* interface и подаёт результат в [SkillMiningComponent.events]. Делаем так,
|
||||||
|
* чтобы модуль :skill-mining не зависел от :standalone и его внутренних типов.
|
||||||
|
*/
|
||||||
|
sealed interface SkillMiningEvent {
|
||||||
|
val conversationId: String
|
||||||
|
|
||||||
|
/** Разговор закрывается — последний шанс вытащить скилы из контекста. */
|
||||||
|
data class ConversationClosing(override val conversationId: String) : SkillMiningEvent
|
||||||
|
|
||||||
|
/** Сработал compactor — старые turn'ы ушли, mining нужен на новом контексте. */
|
||||||
|
data class ConversationCompacted(override val conversationId: String) : SkillMiningEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полная подсистема скилов как один Component:
|
||||||
|
* 1. Тулы skill_save / skill_delete / skill_read — через [SkillToolProvider].
|
||||||
|
* 2. Секция системного промпта с актуальным каталогом — через [SkillSystemProvider].
|
||||||
|
* 3. Фоновый майнер — на [SkillMiningEvent.ConversationClosing] /
|
||||||
|
* [SkillMiningEvent.ConversationCompacted] вызывает [SkillMiner] над
|
||||||
|
* последними turn'ами разговора и пишет новые скилы в [SkillStore].
|
||||||
|
*
|
||||||
|
* Поведение майнера зависит от per-conversation state ([ConversationHandle]):
|
||||||
|
* - isTemporal=true → пропускаем (mining не имеет смысла для temp-бесед);
|
||||||
|
* - recentTurns(limit) → что майнер видит.
|
||||||
|
*
|
||||||
|
* Подключается как обычный Component: `agent.install(SkillMiningComponent(...))`.
|
||||||
|
* Дополнительно компонент реализует [ConversationAware] — [MutableAgent]
|
||||||
|
* оповещает его о создании/закрытии каждого разговора.
|
||||||
|
*/
|
||||||
|
class SkillMiningComponent(
|
||||||
|
private val store: SkillStore,
|
||||||
|
private val llm: LiteLlm,
|
||||||
|
private val events: Flow<SkillMiningEvent>,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) : Component, ConversationAware {
|
||||||
|
|
||||||
|
private var toolProvider: SkillToolProvider? = null
|
||||||
|
private var systemProvider: SkillSystemProvider? = null
|
||||||
|
private var minerJob: Job? = null
|
||||||
|
|
||||||
|
/** Per-conversation context для mining: id → handle. */
|
||||||
|
private val handles = mutableMapOf<String, ConversationHandle>()
|
||||||
|
private val handlesLock = Mutex()
|
||||||
|
|
||||||
|
override fun install(agent: MutableAgent) {
|
||||||
|
val tp = SkillToolProvider(store)
|
||||||
|
agent.toolProviders.add(tp)
|
||||||
|
toolProvider = tp
|
||||||
|
|
||||||
|
val sp = SkillSystemProvider(store)
|
||||||
|
agent.systemProviders.add(sp)
|
||||||
|
systemProvider = sp
|
||||||
|
|
||||||
|
// Подписка на фоновые события — единая на компонент (не на разговор).
|
||||||
|
minerJob = scope.launch {
|
||||||
|
events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is SkillMiningEvent.ConversationClosing,
|
||||||
|
is SkillMiningEvent.ConversationCompacted -> {
|
||||||
|
mineForConversation(event.conversationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uninstall(agent: MutableAgent) {
|
||||||
|
minerJob?.cancel()
|
||||||
|
minerJob = null
|
||||||
|
toolProvider?.let { agent.toolProviders.remove(it) }
|
||||||
|
systemProvider?.let { agent.systemProviders.remove(it) }
|
||||||
|
// очистка под mutex: detach-вызовы могут быть в полёте из других корутин
|
||||||
|
runBlocking { handlesLock.withLock { handles.clear() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [ConversationAware]: новый разговор — запоминаем handle. */
|
||||||
|
override fun attachConversation(handle: ConversationHandle) {
|
||||||
|
// attach вызывается синхронно из MutableAgent.install-flow,
|
||||||
|
// целостность важна только между attach/detach и чтением из mining-корутины
|
||||||
|
runBlocking { handlesLock.withLock { handles[handle.id] = handle } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [ConversationAware]: разговор закрыт — забываем handle. */
|
||||||
|
override fun detachConversation(handle: ConversationHandle) {
|
||||||
|
runBlocking { handlesLock.withLock { handles.remove(handle.id) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mineForConversation(conversationId: String) {
|
||||||
|
// горячий путь: одна mining-корутина читает snapshot handle и идёт работать.
|
||||||
|
// блокировка короткая, copy-on-read не нужно — handle'ы immutable в нашей модели.
|
||||||
|
val handle = runBlocking { handlesLock.withLock { handles[conversationId] } } ?: return
|
||||||
|
if (handle.isTemporal) return
|
||||||
|
scope.launch {
|
||||||
|
val miner = SkillMiner(llm)
|
||||||
|
val turns = runCatching { handle.recentTurns(miner.maxTurns) }.getOrDefault(emptyList())
|
||||||
|
if (turns.isEmpty()) return@launch
|
||||||
|
runCatching {
|
||||||
|
// ConversationTurn (agent-api) == ConversationTurn (memory-api) через typealias.
|
||||||
|
miner.mine(turns, store.catalog.skills)
|
||||||
|
}
|
||||||
|
.onSuccess { mined ->
|
||||||
|
mined.forEach { runCatching { store.upsert(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SkillToolProvider(
|
||||||
|
private val store: SkillStore,
|
||||||
|
) : ToolProvider {
|
||||||
|
override fun getTools(conversationId: String): List<LiteTool> {
|
||||||
|
val catalog: SkillCatalog = store.catalog
|
||||||
|
return buildList {
|
||||||
|
add(SkillSaveTool(store))
|
||||||
|
add(SkillDeleteTool(store))
|
||||||
|
if (!catalog.isEmpty) add(SkillReadTool(catalog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SkillSystemProvider(
|
||||||
|
private val store: SkillStore,
|
||||||
|
) : SystemPromptProvider {
|
||||||
|
override fun getSection(conversationId: String): String =
|
||||||
|
store.catalog.renderSystemPromptSection()
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.llm.tools
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import pw.binom.agentik.skills.SkillFile
|
import pw.binom.agentik.skills.SkillFile
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.llm.tools
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import pw.binom.agentik.skills.SkillFile
|
import pw.binom.agentik.skills.SkillFile
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -22,6 +22,8 @@ class SkillReadTool(
|
|||||||
private val catalog: SkillCatalog,
|
private val catalog: SkillCatalog,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
|
|
||||||
|
override val name: String get() = NAME
|
||||||
|
|
||||||
override fun describe(): String = buildJsonObject {
|
override fun describe(): String = buildJsonObject {
|
||||||
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который
|
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который
|
||||||
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
|
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
|
||||||
+11
-18
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import pw.binom.agentik.skills.SkillFile
|
import pw.binom.agentik.skills.SkillFile
|
||||||
import pw.binom.agentik.skills.SkillStore
|
import pw.binom.agentik.skills.SkillStore
|
||||||
@@ -23,30 +23,26 @@ import pw.binom.litert.LiteTool
|
|||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*
|
|
||||||
* ВАЖНО: параметр LiteTool-лямбды не называется `invoke` — иначе Kotlin
|
|
||||||
* резолвит `invoke(x)` рекурсивно и StackOverflow (см. MemoryToolsFactory).
|
|
||||||
*/
|
*/
|
||||||
internal class SkillSaveTool(
|
class SkillSaveTool(
|
||||||
private val store: SkillStore,
|
private val store: SkillStore,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
|
override val name: String = "skill_save"
|
||||||
override fun describe(): String = SCHEMA
|
override fun describe(): String = SCHEMA
|
||||||
|
|
||||||
override fun invoke(arguments: String): String {
|
override fun invoke(arguments: String): String {
|
||||||
val parsed = parseArgs(arguments)
|
val parsed = parseArgs(arguments)
|
||||||
?: return error("invalid arguments: $arguments")
|
?: throw IllegalStateException("invalid arguments: $arguments")
|
||||||
val (name, description, body) = parsed
|
val (name, description, body) = parsed
|
||||||
return try {
|
return try {
|
||||||
store.upsert(SkillFile(name = name, description = description, body = body))
|
store.upsert(SkillFile(name = name, description = description, body = body))
|
||||||
"""{"ok":true,"name":"${escape(name)}"}"""
|
"""{"ok":true,"name":"${escape(name)}"}"""
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error(e.message ?: "upsert failed")
|
throw IllegalStateException(e.message ?: "upsert failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseArgs(arguments: String): Triple<String, String, String>? {
|
private fun parseArgs(arguments: String): Triple<String, String, String>? {
|
||||||
// Минимальный парсер JSON-объекта — вытаскиваем три строковых поля.
|
|
||||||
// Полагаемся на порядок ключей в агенте: name, description, body.
|
|
||||||
val name = extractString(arguments, "name") ?: return null
|
val name = extractString(arguments, "name") ?: return null
|
||||||
val description = extractString(arguments, "description") ?: return null
|
val description = extractString(arguments, "description") ?: return null
|
||||||
val body = extractString(arguments, "body") ?: return null
|
val body = extractString(arguments, "body") ?: return null
|
||||||
@@ -75,22 +71,23 @@ internal class SkillSaveTool(
|
|||||||
/**
|
/**
|
||||||
* Тул `skill_delete(name)` — архивирует скил.
|
* Тул `skill_delete(name)` — архивирует скил.
|
||||||
*/
|
*/
|
||||||
internal class SkillDeleteTool(
|
class SkillDeleteTool(
|
||||||
private val store: SkillStore,
|
private val store: SkillStore,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
|
override val name: String = "skill_delete"
|
||||||
override fun describe(): String = SCHEMA
|
override fun describe(): String = SCHEMA
|
||||||
override fun invoke(arguments: String): String {
|
override fun invoke(arguments: String): String {
|
||||||
val name = extractString(arguments, "name")
|
val name = extractString(arguments, "name")
|
||||||
?: return error("missing 'name' in arguments: $arguments")
|
?: throw IllegalStateException("missing 'name' in arguments: $arguments")
|
||||||
return try {
|
return try {
|
||||||
val removed = store.remove(name)
|
val removed = store.remove(name)
|
||||||
if (removed) {
|
if (removed) {
|
||||||
"""{"ok":true,"archived":"${escape(name)}"}"""
|
"""{"ok":true,"archived":"${escape(name)}"}"""
|
||||||
} else {
|
} else {
|
||||||
error("skill '$name' not found")
|
throw IllegalStateException("skill '$name' not found")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error(e.message ?: "delete failed")
|
throw IllegalStateException(e.message ?: "delete failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +110,7 @@ internal class SkillDeleteTool(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
|
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
|
||||||
* JSON простой (плоский объект с известным набором ключей), как в
|
* JSON простой (плоский объект с известным набором ключей).
|
||||||
* MemoryToolsFactory.
|
|
||||||
*/
|
*/
|
||||||
internal fun extractString(json: String, key: String): String? {
|
internal fun extractString(json: String, key: String): String? {
|
||||||
val keyIdx = json.indexOf("\"$key\"")
|
val keyIdx = json.indexOf("\"$key\"")
|
||||||
@@ -163,6 +159,3 @@ internal fun escape(s: String): String = buildString(s.length + 2) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Строит `{"error":"..."}` JSON-ответ. */
|
|
||||||
internal fun error(message: String): Nothing = throw IllegalStateException(message)
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import pw.binom.litert.LiteContentPart
|
||||||
|
import pw.binom.litert.LiteConversation
|
||||||
|
import pw.binom.litert.LiteConversationConfig
|
||||||
|
import pw.binom.litert.LiteDelta
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.LiteMessage
|
||||||
|
import pw.binom.litert.LiteRole
|
||||||
|
import pw.binom.litert.LiteToolCall
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
|
||||||
|
* заданной строкой [reply] двумя фрагментами + done.
|
||||||
|
*/
|
||||||
|
internal class FakeLiteLlm : LiteLlm {
|
||||||
|
sealed class Reply {
|
||||||
|
data class Text(val text: String) : Reply()
|
||||||
|
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override val backendName: String = "fake"
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||||
|
var reply: String = ""
|
||||||
|
var rememberHistory: Boolean = false
|
||||||
|
var slow: Boolean = false
|
||||||
|
var failMessage: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Если задан, LLM проходит по этому списку ответов по порядку: первый
|
||||||
|
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
|
||||||
|
* кончился — fallback на [reply] (text).
|
||||||
|
*/
|
||||||
|
var scriptedReplies: MutableList<Reply> = mutableListOf()
|
||||||
|
|
||||||
|
var lastConfig: LiteConversationConfig? = null
|
||||||
|
var lastContents: List<LiteContentPart>? = null
|
||||||
|
val conversations = mutableListOf<FakeLiteConversation>()
|
||||||
|
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
|
||||||
|
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
||||||
|
lastConfig = config
|
||||||
|
val conv = FakeLiteConversation(this, config)
|
||||||
|
conversations.add(conv)
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String =
|
||||||
|
throw UnsupportedOperationException("not used in test")
|
||||||
|
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
|
||||||
|
throw UnsupportedOperationException("not used in test")
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
|
||||||
|
fun nextReply(): Reply =
|
||||||
|
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class FakeLiteConversation(
|
||||||
|
private val parent: FakeLiteLlm,
|
||||||
|
config: LiteConversationConfig,
|
||||||
|
) : LiteConversation {
|
||||||
|
val initialMessages: List<LiteMessage> = config.initialMessages
|
||||||
|
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
||||||
|
override val history: List<LiteMessage> get() = mutableHistory.toList()
|
||||||
|
override var systemInstruction: String? = config.systemInstruction
|
||||||
|
override var tools: List<pw.binom.litert.LiteTool> = config.tools
|
||||||
|
|
||||||
|
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
||||||
|
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||||
|
|
||||||
|
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||||
|
parent.lastContents = contents
|
||||||
|
parent.failMessage?.let { msg ->
|
||||||
|
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
|
||||||
|
}
|
||||||
|
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
||||||
|
val next = parent.nextReply()
|
||||||
|
return when (next) {
|
||||||
|
is FakeLiteLlm.Reply.Text -> {
|
||||||
|
if (parent.slow) {
|
||||||
|
kotlinx.coroutines.flow.flow {
|
||||||
|
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
|
||||||
|
kotlinx.coroutines.delay(10_000)
|
||||||
|
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
|
||||||
|
mutableHistory.add(LiteMessage.model(next.text))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val first = next.text.substring(0, next.text.length / 2)
|
||||||
|
val second = next.text.substring(next.text.length / 2)
|
||||||
|
flowOf(
|
||||||
|
LiteDelta(text = first),
|
||||||
|
LiteDelta(text = second, isDone = true),
|
||||||
|
).also { mutableHistory.add(LiteMessage.model(next.text)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FakeLiteLlm.Reply.ToolCalls -> {
|
||||||
|
val calls = next.calls.map { (name, args) ->
|
||||||
|
LiteToolCall(name = name, arguments = args)
|
||||||
|
}
|
||||||
|
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun replaceHistory(newHistory: List<LiteMessage>) {
|
||||||
|
mutableHistory.clear()
|
||||||
|
mutableHistory.addAll(newHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(prompt: String): String {
|
||||||
|
parent.lastContents = listOf(LiteContentPart.Text(prompt))
|
||||||
|
return parent.reply
|
||||||
|
}
|
||||||
|
override fun sendContents(contents: List<LiteContentPart>): String {
|
||||||
|
parent.lastContents = contents
|
||||||
|
return parent.reply
|
||||||
|
}
|
||||||
|
override fun cancel() {}
|
||||||
|
override fun tokenCount(): Int = history.size
|
||||||
|
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
|
||||||
|
LiteDelta(text = "", isDone = true)
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
import pw.binom.agentik.skill.mining.SkillMiner
|
||||||
|
|
||||||
class SkillMinerTest {
|
class SkillMinerTest {
|
||||||
|
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import pw.binom.agentik.llm.tools.SkillMiningParser
|
import pw.binom.agentik.skill.mining.SkillMiningParser
|
||||||
|
|
||||||
class SkillMiningParserTest {
|
class SkillMiningParserTest {
|
||||||
|
|
||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.skill.mining
|
||||||
|
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
import pw.binom.agentik.skills.SkillFile
|
import pw.binom.agentik.skills.SkillFile
|
||||||
@@ -19,7 +19,6 @@ class SkillReadToolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun describeIsFlatOpenApiSchema() {
|
fun describeIsFlatOpenApiSchema() {
|
||||||
val json = tool.describe()
|
val json = tool.describe()
|
||||||
// Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
|
|
||||||
assertTrue("\"name\"" in json)
|
assertTrue("\"name\"" in json)
|
||||||
assertTrue(SkillReadTool.NAME in json)
|
assertTrue(SkillReadTool.NAME in json)
|
||||||
assertTrue("parameters" in json)
|
assertTrue("parameters" in json)
|
||||||
@@ -22,7 +22,7 @@ val skipVectorMemory: Boolean =
|
|||||||
// :standalone — единственный entry-point проекта, JVM-only по факту (один
|
// :standalone — единственный entry-point проекта, JVM-only по факту (один
|
||||||
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
|
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
|
||||||
// commonMain + commonTest — даже зависимости, которые формально JVM-only
|
// commonMain + commonTest — даже зависимости, которые формально JVM-only
|
||||||
// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут
|
// (ktor-server-cio, litert-openai, ...). KMP-плагин тут
|
||||||
// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`,
|
// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`,
|
||||||
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
|
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -64,25 +64,40 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
|
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
|
||||||
implementation(project(":storage-ksqlite"))
|
implementation(project(":journal-ksqlite"))
|
||||||
|
implementation(project(":context-ksqlite"))
|
||||||
|
implementation(project(":reflection-ksqlite"))
|
||||||
|
// ksqlite объявлен как `implementation` (не `api`) в каждом
|
||||||
|
// ksqlite-модуле, поэтому SqliteStores здесь использует
|
||||||
|
// SQLiteConnection напрямую — фиксируем зависимость явно.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
|
|
||||||
// Bounded-tail live event stream + per-event TTL.
|
// Bounded-tail live event stream + per-event TTL.
|
||||||
implementation(project(":outbox-inmemory"))
|
implementation(project(":outbox-inmemory"))
|
||||||
|
|
||||||
|
// :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider.
|
||||||
|
// ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.)
|
||||||
|
// добавляются через agent.install(...).
|
||||||
|
implementation(project(":agent-api"))
|
||||||
|
|
||||||
// LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner,
|
// LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner,
|
||||||
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
|
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
|
||||||
implementation(project(":agent-toolsets"))
|
implementation(project(":agent-toolsets"))
|
||||||
implementation(project(":llm-tools"))
|
implementation(project(":llm-tools"))
|
||||||
|
// Skill-подсистема как Component: тулы + SkillMiningComponent.
|
||||||
|
implementation(project(":skill-mining"))
|
||||||
|
|
||||||
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
||||||
implementation(project(":mcp-bridge"))
|
implementation(project(":mcp-bridge"))
|
||||||
|
|
||||||
// litert-kmp: контракт (commonMain)
|
// litert-kmp: контракт (commonMain)
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
// liteTool DSL (типизированные LiteTool через @Serializable args)
|
||||||
|
implementation(libs.litert.tools.kotlinx.serialization)
|
||||||
// litert-openai: JVM-реализация
|
// litert-openai: JVM-реализация
|
||||||
implementation(libs.litert.openai)
|
api(libs.litert.openai)
|
||||||
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
||||||
runtimeOnly(libs.litert.google)
|
api(libs.litert.google)
|
||||||
|
|
||||||
// Ktor server (для :server facade + a2aServer)
|
// Ktor server (для :server facade + a2aServer)
|
||||||
// Используем CIO вместо Netty — он KMP (jvm + linuxX64/ios/...), нам нужен
|
// Используем CIO вместо Netty — он KMP (jvm + linuxX64/ios/...), нам нужен
|
||||||
@@ -183,9 +198,9 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
|||||||
// выровнено по стилю существующего деплоя).
|
// выровнено по стилю существующего деплоя).
|
||||||
//
|
//
|
||||||
// SSH — ключевой, пароль не спрашивает. Если упадёт — проверь
|
// SSH — ключевой, пароль не спрашивает. Если упадёт — проверь
|
||||||
// `ssh root@192.168.76.166 'echo OK'` вручную.
|
// `ssh root@192.168.76.156 'echo OK'` вручную.
|
||||||
val agentikDeployHost: String =
|
val agentikDeployHost: String =
|
||||||
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.166"
|
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.156"
|
||||||
val agentikDeployPath: String =
|
val agentikDeployPath: String =
|
||||||
(project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar"
|
(project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar"
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import pw.binom.agentik.reflection.ReflectionStore
|
|||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.standalone.agent.ChatConversation
|
import pw.binom.agentik.standalone.agent.ChatConversation
|
||||||
import pw.binom.agentik.llm.tools.LlmReflector
|
import pw.binom.agentik.llm.tools.LlmReflector
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
import pw.binom.agentik.skill.mining.SkillMiner
|
||||||
import pw.binom.agentik.standalone.agent.memory.Curator
|
import pw.binom.agentik.standalone.agent.memory.Curator
|
||||||
import pw.binom.agentik.skills.SkillStore
|
import pw.binom.agentik.skills.SkillStore
|
||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
|
|||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.ModelDownloader
|
import pw.binom.agentik.standalone.llm.ModelDownloader
|
||||||
import pw.binom.agentik.mcp.bridge.McpRegistry
|
import pw.binom.agentik.mcp.bridge.McpRegistry
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
import pw.binom.agentik.skill.mining.SkillMiner
|
||||||
/**
|
/**
|
||||||
* standalone-контейнер agentik:
|
* standalone-контейнер agentik:
|
||||||
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
||||||
@@ -199,7 +199,7 @@ private fun runServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val llm = config.llm.createLlm()
|
val llm = config.llm.createLlm()
|
||||||
val sqliteStores = KsqliteStores.open(path = config.agent.dbPath)
|
val sqliteStores = SqliteStores.open(path = config.agent.dbPath)
|
||||||
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
||||||
|
|
||||||
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
|
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
|
||||||
@@ -214,9 +214,9 @@ private fun runServer() {
|
|||||||
// Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы,
|
// Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы,
|
||||||
// которые модель забыла сохранить через `skill_save`. Работает только когда
|
// которые модель забыла сохранить через `skill_save`. Работает только когда
|
||||||
// есть куда писать (skillStore) и интервал > 0.
|
// есть куда писать (skillStore) и интервал > 0.
|
||||||
val skillMiner: pw.binom.agentik.llm.tools.SkillMiner? =
|
val skillMiner: pw.binom.agentik.skill.mining.SkillMiner? =
|
||||||
if (skillStore != null && config.skillMining.interval > 0) {
|
if (skillStore != null && config.skillMining.interval > 0) {
|
||||||
pw.binom.agentik.llm.tools.SkillMiner(
|
pw.binom.agentik.skill.mining.SkillMiner(
|
||||||
llm = llm,
|
llm = llm,
|
||||||
maxTurns = config.skillMining.maxTurns,
|
maxTurns = config.skillMining.maxTurns,
|
||||||
)
|
)
|
||||||
@@ -369,7 +369,6 @@ private fun runServer() {
|
|||||||
reflectionStore = sqliteStores.reflections,
|
reflectionStore = sqliteStores.reflections,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
llmConfig = config.llm,
|
llmConfig = config.llm,
|
||||||
tools = mcpRegistry.namedTools,
|
|
||||||
skills = skills,
|
skills = skills,
|
||||||
skillStore = skillStore,
|
skillStore = skillStore,
|
||||||
memoryStore = memorySystem?.store,
|
memoryStore = memorySystem?.store,
|
||||||
@@ -381,8 +380,7 @@ private fun runServer() {
|
|||||||
contextCompactor = contextCompactor,
|
contextCompactor = contextCompactor,
|
||||||
recentReflections = recentReflections,
|
recentReflections = recentReflections,
|
||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
skillMiner = skillMiner,
|
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
|
||||||
)
|
|
||||||
|
|
||||||
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
|
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
|
||||||
// чтобы debug-эндпоинты могли его триггерить вручную.
|
// чтобы debug-эндпоинты могли его триггерить вручную.
|
||||||
|
|||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal event bus для background work — separate from [ConversationEvents]
|
|
||||||
* (который это SSE-event stream для клиента).
|
|
||||||
*
|
|
||||||
* Background work fires на **structural events**, не на interval-polling:
|
|
||||||
* - **Review** — триггерится в CompactionCoordinator ПРЯМО ПЕРЕД удалением ходов
|
|
||||||
* из working memory (last chance вытащить факты). Это уже было сделано
|
|
||||||
* через `reviewer.reviewPreCompaction()` — оставляем как есть.
|
|
||||||
* - **Reflection** — на `ConversationLifecycleEvent.Closing` (финальная
|
|
||||||
* рефлексия перед закрытием) ИЛИ накопление N tool-failures в окне
|
|
||||||
* (что-то идёт не так).
|
|
||||||
* - **Skill mining** — на `CompactionEvent.Triggered` если `turnsToDelete > N`
|
|
||||||
* (есть контент для минера) ИЛИ на `ConversationLifecycleEvent.Closing`.
|
|
||||||
*
|
|
||||||
* Subscribers (BackgroundScheduler) решают, что делать. НЕ текстовая
|
|
||||||
* инспекция, НЕ regex — только структурные события с явным семантическим
|
|
||||||
* смыслом. См. STANDALONE-REVIEW раздел "event-driven background".
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Эмитится из [ToolDispatcher] после каждого `runToolAndPersist` (success/failure). */
|
|
||||||
sealed interface ToolCallEvent {
|
|
||||||
val toolName: String
|
|
||||||
|
|
||||||
data class Succeeded(
|
|
||||||
override val toolName: String,
|
|
||||||
val durationMs: Long,
|
|
||||||
) : ToolCallEvent
|
|
||||||
|
|
||||||
data class Failed(
|
|
||||||
override val toolName: String,
|
|
||||||
val error: String,
|
|
||||||
) : ToolCallEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Эмитится из [CompactionCoordinator] ПЕРЕД `workingMemory.compact(...)`. */
|
|
||||||
sealed interface CompactionEvent {
|
|
||||||
/**
|
|
||||||
* Compaction сейчас удалит N ходов из working memory. Background work
|
|
||||||
* имеет последний шанс вытащить оттуда данные.
|
|
||||||
*/
|
|
||||||
data class Triggered(
|
|
||||||
val turnsToDelete: Int,
|
|
||||||
val conversationId: String,
|
|
||||||
) : CompactionEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Эмитится из `ConversationLoop.close()` сразу ПЕРЕД `agentScope.cancel()`. */
|
|
||||||
sealed interface ConversationLifecycleEvent {
|
|
||||||
data class Closing(val conversationId: String) : ConversationLifecycleEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class BackgroundEventBus {
|
|
||||||
private val _toolCallEvents = MutableSharedFlow<ToolCallEvent>(
|
|
||||||
replay = 0,
|
|
||||||
extraBufferCapacity = 256,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
|
||||||
)
|
|
||||||
val toolCallEvents: SharedFlow<ToolCallEvent> get() = _toolCallEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _compactionEvents = MutableSharedFlow<CompactionEvent>(
|
|
||||||
replay = 0,
|
|
||||||
extraBufferCapacity = 16,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
|
||||||
)
|
|
||||||
val compactionEvents: SharedFlow<CompactionEvent> get() = _compactionEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _lifecycleEvents = MutableSharedFlow<ConversationLifecycleEvent>(
|
|
||||||
replay = 0,
|
|
||||||
extraBufferCapacity = 16,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
|
||||||
)
|
|
||||||
val lifecycleEvents: SharedFlow<ConversationLifecycleEvent> get() = _lifecycleEvents.asSharedFlow()
|
|
||||||
|
|
||||||
fun tryEmit(event: ToolCallEvent): Boolean = _toolCallEvents.tryEmit(event)
|
|
||||||
fun tryEmit(event: CompactionEvent): Boolean = _compactionEvents.tryEmit(event)
|
|
||||||
fun tryEmit(event: ConversationLifecycleEvent): Boolean = _lifecycleEvents.tryEmit(event)
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,41 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.agent.Component
|
||||||
|
import pw.binom.agentik.agent.ConversationAware
|
||||||
|
import pw.binom.agentik.agent.ConversationHandle
|
||||||
|
import pw.binom.agentik.agent.ConversationTurn
|
||||||
|
import pw.binom.agentik.agent.MutableAgent
|
||||||
|
import pw.binom.agentik.agent.SystemPromptProvider
|
||||||
|
import pw.binom.agentik.agent.ToolProvider
|
||||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||||
import pw.binom.agentik.llm.tools.LlmReflector
|
import pw.binom.agentik.llm.tools.LlmReflector
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
|
||||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
import pw.binom.agentik.memory.MemoryReviewer
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
import pw.binom.agentik.memory.MemorySystemGuidance
|
import pw.binom.agentik.memory.MemorySystemGuidance
|
||||||
import pw.binom.agentik.proto.Agent as ProtoAgent
|
import pw.binom.agentik.proto.Agent as ProtoAgent
|
||||||
import pw.binom.agentik.outbox.AgentEvent
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.Event as OutboxEvent
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
import pw.binom.agentik.skills.renderSystemPromptSection
|
import pw.binom.agentik.standalone.agent.memory.MemoryComponent
|
||||||
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
import pw.binom.agentik.standalone.agent.soul.SoulComponent
|
||||||
|
import pw.binom.agentik.skill.mining.SkillMiningComponent
|
||||||
|
import pw.binom.agentik.skill.mining.SkillMiningEvent
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.journal.ConversationStore
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
@@ -30,16 +46,17 @@ import pw.binom.agentik.journal.MutableJournalStore
|
|||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.toolsets.DisableToolsetTool
|
import pw.binom.agentik.toolsets.DisableToolsetTool
|
||||||
import pw.binom.agentik.toolsets.EnableToolsetTool
|
import pw.binom.agentik.toolsets.EnableToolsetTool
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
import pw.binom.litert.LiteTool
|
||||||
import pw.binom.agentik.toolsets.SystemPromptToolsetSection
|
import pw.binom.agentik.toolsets.SystemPromptToolsetSection
|
||||||
|
import pw.binom.agentik.toolsets.ToolsetComponent
|
||||||
import pw.binom.agentik.toolsets.ToolsetContribution
|
import pw.binom.agentik.toolsets.ToolsetContribution
|
||||||
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||||
import pw.binom.agentik.toolsets.ToolsetRegistry
|
import pw.binom.agentik.toolsets.ToolsetRegistry
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
|
* Stateful [MutableAgent] (= [ProtoAgent] + install/uninstall/component extension)
|
||||||
* [LiteLlm] (универсальный LLM-контракт).
|
* на базе SQLite (история + working memory) и [LiteLlm] (универсальный LLM-контракт).
|
||||||
*
|
*
|
||||||
* Создаёт [ChatConversation] — те самые stateful диалоги, которые
|
* Создаёт [ChatConversation] — те самые stateful диалоги, которые
|
||||||
* хранят свой собственный LiteConversation в ОЗУ (для скорости) и
|
* хранят свой собственный LiteConversation в ОЗУ (для скорости) и
|
||||||
@@ -47,6 +64,13 @@ import pw.binom.litert.LiteLlm
|
|||||||
*
|
*
|
||||||
* Один [LiteLlm] шарится между всеми беседами агента.
|
* Один [LiteLlm] шарится между всеми беседами агента.
|
||||||
*
|
*
|
||||||
|
* **External тулы (MCP, etc.)** приходят не через constructor, а через
|
||||||
|
* [Component.install]: типичный путь — `McpBridgeComponent` из `:mcp-bridge`
|
||||||
|
* добавляет [ToolProvider] в [toolProviders] при [install]. На каждом
|
||||||
|
* `send()` или создании беседы агент вызывает [collectTools] чтобы
|
||||||
|
* собрать свежий список тулов (= внешние через [toolProviders] + встроенные).
|
||||||
|
* Это позволяет добавлять/убирать MCP-серверы в runtime без рестарта.
|
||||||
|
*
|
||||||
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в
|
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в
|
||||||
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для
|
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для
|
||||||
* загрузки полного текста навыка по требованию.
|
* загрузки полного текста навыка по требованию.
|
||||||
@@ -57,7 +81,7 @@ import pw.binom.litert.LiteLlm
|
|||||||
* `ChatConversation` настраивается prefetch (контекст в начале user-сообщения)
|
* `ChatConversation` настраивается prefetch (контекст в начале user-сообщения)
|
||||||
* и post-turn reviewer (извлечение фактов после каждого хода).
|
* и post-turn reviewer (извлечение фактов после каждого хода).
|
||||||
*/
|
*/
|
||||||
class ChatAgent(
|
internal class ChatAgent(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
private val mutableConversationStore: MutableConversationStore,
|
private val mutableConversationStore: MutableConversationStore,
|
||||||
private val messageStore: MutableJournalStore,
|
private val messageStore: MutableJournalStore,
|
||||||
@@ -65,7 +89,6 @@ class ChatAgent(
|
|||||||
private val reflectionStore: ReflectionStore,
|
private val reflectionStore: ReflectionStore,
|
||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
private val llmConfig: LlmConfig,
|
private val llmConfig: LlmConfig,
|
||||||
private val tools: List<NamedTool> = emptyList(),
|
|
||||||
private val skills: SkillCatalog = SkillCatalog.EMPTY,
|
private val skills: SkillCatalog = SkillCatalog.EMPTY,
|
||||||
/**
|
/**
|
||||||
* Хранилище скилов для self-improvement (запись/архивирование). Если
|
* Хранилище скилов для self-improvement (запись/архивирование). Если
|
||||||
@@ -76,6 +99,7 @@ class ChatAgent(
|
|||||||
private val skillStore: pw.binom.agentik.skills.SkillStore? = null,
|
private val skillStore: pw.binom.agentik.skills.SkillStore? = null,
|
||||||
private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null,
|
private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null,
|
||||||
private val memoryPrefetcher: MemoryPrefetcher? = null,
|
private val memoryPrefetcher: MemoryPrefetcher? = null,
|
||||||
|
internal val agentScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
private val memoryReviewer: MemoryReviewer? = null,
|
private val memoryReviewer: MemoryReviewer? = null,
|
||||||
/**
|
/**
|
||||||
* Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
|
* Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
|
||||||
@@ -105,96 +129,63 @@ class ChatAgent(
|
|||||||
* Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен.
|
* Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен.
|
||||||
*/
|
*/
|
||||||
private val reflector: LlmReflector? = null,
|
private val reflector: LlmReflector? = null,
|
||||||
/**
|
|
||||||
* Фоновый минер скилов: LLM-вызов, который запускается на compaction
|
|
||||||
* (`turnsToDelete > 10`) или при closing conversation. `null` = mining выключен.
|
|
||||||
* Сетка безопасности, если модель забыла вызвать `skill_save` сама.
|
|
||||||
*/
|
|
||||||
private val skillMiner: SkillMiner? = null,
|
|
||||||
/**
|
/**
|
||||||
* Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает
|
* Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает
|
||||||
* о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются,
|
* о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются,
|
||||||
* секция в system prompt НЕ добавляется (полная невидимость per A1-α).
|
* секция в system prompt НЕ добавляется (полная невидимость per A1-α).
|
||||||
*/
|
*/
|
||||||
private val toolsets: List<ToolsetContribution> = emptyList(),
|
private val toolsets: List<ToolsetContribution> = emptyList(),
|
||||||
) : ProtoAgent, AutoCloseable {
|
) : MutableAgent, AutoCloseable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only: регистрирует дополнительный tool в общий [toolsByName] ПОСЛЕ
|
* Провайдеры внешних/динамических тулов (MCP, etc.). Заполняются
|
||||||
* создания ChatAgent. Используется в тестах `interrupt mid-tool` для
|
* компонентами через [install]. На каждом [collectTools] агент
|
||||||
* симуляции долгого tool-вызова, который можно прервать через interrupt().
|
* спрашивает у всех провайдеров их список тулов.
|
||||||
* В production этот API НЕ используется — тулы статичны через конструктор.
|
*
|
||||||
|
* Изменение этого списка (через [install]/[uninstall]) сразу же отражается
|
||||||
|
* на tool-dispatch следующего turn'а любой беседы.
|
||||||
*/
|
*/
|
||||||
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) {
|
override val toolProviders: MutableList<ToolProvider> = mutableListOf()
|
||||||
toolsByName[name] = NamedTool(name = name, tool = tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реестр активных тулсетов — один на агента (per-agent state).
|
* Провайдеры секций системного промпта. Заполняются компонентами
|
||||||
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations.
|
* через [install]. На каждом turn'е агент собирает секции через
|
||||||
|
* [SystemPromptProvider.getSection].
|
||||||
|
*/
|
||||||
|
override val systemProviders: MutableList<SystemPromptProvider> = mutableListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установленные компоненты. В [close] проходим в обратном порядке
|
||||||
|
* и дёргаем [Component.uninstall], чтобы каждый успел закрыть свои ресурсы.
|
||||||
|
*/
|
||||||
|
private val components: MutableList<Component> = mutableListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр активных тулсетов — один на агента (per-agent state). Раньше
|
||||||
|
* жил прямо в ChatAgent, теперь поднимается через [ToolsetComponent]
|
||||||
|
* (см. init-блок ниже). Доступ к реестру — через [toolsetDispatch].
|
||||||
*/
|
*/
|
||||||
private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets)
|
private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets)
|
||||||
|
|
||||||
/** Список активных тулов (включая enable/disable если есть тулсеты). */
|
|
||||||
private val enabledToolsetTools: List<NamedTool> = if (toolsets.isNotEmpty()) {
|
|
||||||
listOf(
|
|
||||||
NamedTool(EnableToolsetTool.NAME, EnableToolsetTool(toolsetRegistry).tool),
|
|
||||||
NamedTool(DisableToolsetTool.NAME, DisableToolsetTool(toolsetRegistry).tool),
|
|
||||||
)
|
|
||||||
} else emptyList()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти
|
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти
|
||||||
* → секция self-reflection (слабые места) → секция тулсетов (если они заданы).
|
* → секция self-reflection (слабые места). Секция тулсетов теперь приходит
|
||||||
* Именно он сидируется в working memory и передаётся в [ChatConversation].
|
* динамически от [ToolsetComponent] через [systemProviders].
|
||||||
*/
|
*/
|
||||||
private val systemPrompt: String = buildSystemPrompt(
|
private val baseSystemPrompt: String = buildSystemPrompt(
|
||||||
base = llmConfig.systemPrompt,
|
base = llmConfig.systemPrompt,
|
||||||
skills = skills,
|
|
||||||
memoryEnabled = memoryStore != null,
|
memoryEnabled = memoryStore != null,
|
||||||
soulBody = soulBody,
|
|
||||||
reflections = recentReflections,
|
reflections = recentReflections,
|
||||||
toolsetSection = if (toolsets.isNotEmpty()) {
|
|
||||||
SystemPromptToolsetSection.render(
|
|
||||||
active = emptyList(), // все по умолчанию неактивны
|
|
||||||
inactive = toolsets,
|
|
||||||
)
|
|
||||||
} else null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные:
|
* Тестовый инжектор: позволяет `ChatAgentTest` подсунуть тул, который
|
||||||
* `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть
|
* скриптованная LLM будет вызывать. В production-конфигурации всегда пуст —
|
||||||
* [skillStore]), `memory_*` (если подключена память),
|
* инструменты приходят через [toolProviders] (компоненты, [install]).
|
||||||
* `enable_toolset`/`disable_toolset` (если заданы [toolsets]).
|
|
||||||
* MCP-тулы префиксованы `server__`, так что коллизий нет.
|
|
||||||
*/
|
*/
|
||||||
private val allTools: List<NamedTool> = buildList {
|
private val testTools: MutableList<LiteTool> = mutableListOf()
|
||||||
addAll(tools)
|
|
||||||
addAll(enabledToolsetTools)
|
|
||||||
if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills)))
|
|
||||||
if (skillStore != null) addAll(SkillToolsFactory.create(skillStore))
|
|
||||||
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
|
|
||||||
}
|
|
||||||
|
|
||||||
private val toolsByName: MutableMap<String, NamedTool> = allTools.associateBy { it.name }.toMutableMap()
|
/**
|
||||||
|
|
||||||
/**
|
|
||||||
* Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда
|
|
||||||
* [toolsets] пустой (тогда работает как passthrough через baseDispatcher).
|
|
||||||
* Это позволяет ChatConversation.runTurn всегда идти через один путь,
|
|
||||||
* без ветвления «с тулсетами / без».
|
|
||||||
*/
|
|
||||||
private val toolsetDispatch: ToolsetDispatchPolicy = ToolsetDispatchPolicy(
|
|
||||||
registry = toolsetRegistry,
|
|
||||||
baseDispatcher = { name, args ->
|
|
||||||
val t = toolsByName[name]
|
|
||||||
?: error("unknown tool: $name")
|
|
||||||
t.tool.invoke(args)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Единый канал всех событий агента — bounded tail с auto-TTL.
|
* Единый канал всех событий агента — bounded tail с auto-TTL.
|
||||||
*
|
*
|
||||||
* Заменил ранее существовавшие два канала:
|
* Заменил ранее существовавшие два канала:
|
||||||
@@ -207,11 +198,190 @@ class ChatAgent(
|
|||||||
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
|
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
|
||||||
* или подписке на два отдельных канала.
|
* или подписке на два отдельных канала.
|
||||||
*/
|
*/
|
||||||
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
|
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
|
||||||
maxMessages = null,
|
maxMessages = null,
|
||||||
ttl = null,
|
ttl = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает **актуальный** список тулов для диспетчеризации:
|
||||||
|
* внешние из [toolProviders] + testTools + встроенные (read_skill,
|
||||||
|
* skill_save, memory_*, toolset enable/disable).
|
||||||
|
* Вызывается **на каждый tool-call** через [toolsetDispatch] (поэтому без
|
||||||
|
* кэша — добавление компонента сразу видно в dispatch).
|
||||||
|
*
|
||||||
|
* Дедупликация не делается — если два провайдера вернут тул с одинаковым
|
||||||
|
* именем, второй в lookup'е победит первый. Это намеренно оставлено на
|
||||||
|
* ответственности компонентов (в типичной конфигурации каждый компонент
|
||||||
|
* отвечает за свой namespace-префикс, и коллизий нет).
|
||||||
|
*/
|
||||||
|
internal fun collectTools(): List<LiteTool> = buildList {
|
||||||
|
toolProviders.forEach { addAll(it.getTools(conversationId = "")) }
|
||||||
|
addAll(testTools)
|
||||||
|
// Skill-* тулы приходят через SkillComponent → ToolProvider.
|
||||||
|
// memory_* тулы приходят через MemoryComponent → ToolProvider.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестовый хук: регистрирует [tool] под именем [name] в общем пуле.
|
||||||
|
* Используется только в `ChatAgentTest`. В production-конфигурации тулы
|
||||||
|
* приходят через [install] компонентов с [ToolProvider].
|
||||||
|
*/
|
||||||
|
internal fun addTestTool(tool: LiteTool) {
|
||||||
|
testTools.add(tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда
|
||||||
|
* [toolsets] пустой (тогда работает как passthrough через baseDispatcher).
|
||||||
|
* Это позволяет ChatConversation.runTurn всегда идти через один путь,
|
||||||
|
* без ветвления «с тулсетами / без».
|
||||||
|
*
|
||||||
|
* `baseDispatcher` лениво пересобирает список тулов через [collectTools]
|
||||||
|
* — это значит добавление/удаление компонента в runtime сразу же видно
|
||||||
|
* в tool-dispatch следующего turn'а.
|
||||||
|
*/
|
||||||
|
private val toolsetDispatch: ToolsetDispatchPolicy = ToolsetDispatchPolicy(
|
||||||
|
registry = toolsetRegistry,
|
||||||
|
baseDispatcher = { name, args ->
|
||||||
|
val t = collectTools().firstOrNull { it.name == name }
|
||||||
|
?: error("unknown tool: $name")
|
||||||
|
t.invoke(args)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Поднимаем механику toolsets как обычный Component — теперь тулсеты
|
||||||
|
// могут быть добавлены/убраны в runtime через тот же [install] API.
|
||||||
|
if (toolsets.isNotEmpty()) install(ToolsetComponent(toolsets))
|
||||||
|
// Память: тулы + system-provider — отдельным компонентом.
|
||||||
|
if (memoryStore != null) install(MemoryComponent(memoryStore))
|
||||||
|
// Скиллы: тулы + system-provider + фоновый mining.
|
||||||
|
if (skillStore != null) install(
|
||||||
|
SkillMiningComponent(
|
||||||
|
store = skillStore,
|
||||||
|
llm = llm,
|
||||||
|
events = skillMiningEvents(),
|
||||||
|
scope = agentScope,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Душа: первая секция системного промпта.
|
||||||
|
if (!soulBody.isNullOrBlank()) install(SoulComponent(soulBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Системный промпт для беседы — статичная база [baseSystemPrompt] плюс
|
||||||
|
// динамические секции из [systemProviders] на момент turn'а.
|
||||||
|
// В ConversationLoop передаётся через [systemPromptResolver] лямбду,
|
||||||
|
// которая дёргается на каждом `sendStreamContents` — LiteRT v12 setter
|
||||||
|
// сам делает no-op, если значение эквивалентно.
|
||||||
|
private val systemPrompt: String = baseSystemPrompt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписка на skill-mining через outbox (единый канал всех событий):
|
||||||
|
* - [OutboxEvent.ConversationClosing] → [SkillMiningEvent.ConversationClosing]
|
||||||
|
* - [OutboxEvent.CompactionTriggered] → [SkillMiningEvent.ConversationCompacted]
|
||||||
|
*
|
||||||
|
* Оба маппатся в один [Flow], `SkillMiningComponent` сам решает что делать.
|
||||||
|
*/
|
||||||
|
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
|
||||||
|
eventStore.events(after = null)
|
||||||
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
|
.mapNotNull { ce ->
|
||||||
|
when (val e = ce.event) {
|
||||||
|
is OutboxEvent.ConversationClosing -> SkillMiningEvent.ConversationClosing(ce.conversationId)
|
||||||
|
is OutboxEvent.CompactionTriggered -> SkillMiningEvent.ConversationCompacted(ce.conversationId)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildRuntimeSystemPrompt(): String {
|
||||||
|
// Секции от systemProviders идут ПЕРЕД базой — это позволяет компонентам
|
||||||
|
// типа SoulComponent поставить себя во главу угла. Если нужна секция
|
||||||
|
// после базы — у компонента должен быть какой-то признак "prepend: false"
|
||||||
|
// (сейчас все systemProviders считаются prepend-only).
|
||||||
|
if (systemProviders.isEmpty()) return systemPrompt
|
||||||
|
val sections = systemProviders.mapNotNull { p ->
|
||||||
|
val section = p.getSection(conversationId = "")
|
||||||
|
section.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
if (sections.isEmpty()) return systemPrompt
|
||||||
|
return sections.joinToString("\n\n") + "\n\n" + systemPrompt.trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прокидывает [component] в [MutableAgent] и запоминает для [close]. */
|
||||||
|
override fun install(component: Component): MutableAgent {
|
||||||
|
component.install(this)
|
||||||
|
components.add(component)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снимает [component]. Идемпотентно — повторный uninstall безопасен. */
|
||||||
|
override fun uninstall(component: Component): MutableAgent {
|
||||||
|
component.uninstall(this)
|
||||||
|
components.remove(component)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оповещает [ConversationAware] компоненты о новом разговоре. Вызывается
|
||||||
|
* из [ChatConversation] сразу после конструктора — компонент может
|
||||||
|
* подписаться на events и запустить фоновые задачи.
|
||||||
|
*/
|
||||||
|
override fun attachConversation(handle: ConversationHandle) {
|
||||||
|
components.filterIsInstance<ConversationAware>().forEach { it.attachConversation(handle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
|
||||||
|
override fun detachConversation(handle: ConversationHandle) {
|
||||||
|
components.filterIsInstance<ConversationAware>().forEach { it.detachConversation(handle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проекция ChatConversation → ConversationHandle. Даём per-conversation
|
||||||
|
* компонентам только минимальный view (id / isTemporal / recentTurns),
|
||||||
|
* чтобы они не лезли в чужие обязанности.
|
||||||
|
*
|
||||||
|
* [recentTurns] берёт последние [limit] реплик из working memory —
|
||||||
|
* это всё, что нужно [pw.binom.agentik.skill.mining.SkillMiningComponent] /
|
||||||
|
* [LlmReflector] для добычи знаний.
|
||||||
|
*/
|
||||||
|
private fun ChatConversation.asHandle(): ConversationHandle = object : ConversationHandle {
|
||||||
|
override val id: String get() = this@asHandle.id
|
||||||
|
override val isTemporal: Boolean get() = this@asHandle.isTemporal
|
||||||
|
override suspend fun recentTurns(limit: Int): List<ConversationTurn> {
|
||||||
|
val rows = workingMemoryStore.list(this@asHandle.id)
|
||||||
|
val filtered = rows.map { it.entry }.filter {
|
||||||
|
it is pw.binom.agentik.context.WorkingMemoryEntry.User ||
|
||||||
|
it is pw.binom.agentik.context.WorkingMemoryEntry.Assistant
|
||||||
|
}
|
||||||
|
// Схлопываем User/Assistant в пары user+assistant.
|
||||||
|
// Если последним идёт User без пары — assistantMessage пустой.
|
||||||
|
val pairs = mutableListOf<ConversationTurn>()
|
||||||
|
var pendingUser: String? = null
|
||||||
|
for (entry in filtered.takeLast(limit * 2)) {
|
||||||
|
val text = when (entry) {
|
||||||
|
is pw.binom.agentik.context.WorkingMemoryEntry.User ->
|
||||||
|
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
|
||||||
|
.joinToString("\n") { it.body }
|
||||||
|
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant ->
|
||||||
|
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
|
||||||
|
.joinToString("\n") { it.body }
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
when (entry) {
|
||||||
|
is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = text
|
||||||
|
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> {
|
||||||
|
pairs += ConversationTurn(userMessage = pendingUser.orEmpty(), assistantMessage = text)
|
||||||
|
pendingUser = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingUser?.let { pairs += ConversationTurn(userMessage = it, assistantMessage = "") }
|
||||||
|
return pairs.takeLast(limit)
|
||||||
|
}
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only view of [messageStore] для HTTP-фасада в `:server`
|
* Read-only view of [messageStore] для HTTP-фасада в `:server`
|
||||||
* (`Route.agentikAgent` → `/journal/...` endpoint'ы).
|
* (`Route.agentikAgent` → `/journal/...` endpoint'ы).
|
||||||
@@ -278,7 +448,9 @@ class ChatAgent(
|
|||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
tools = allTools,
|
systemPromptResolver = { buildRuntimeSystemPrompt() },
|
||||||
|
toolsResolver = { collectTools() },
|
||||||
|
tools = collectTools(),
|
||||||
toolsetDispatch = toolsetDispatch,
|
toolsetDispatch = toolsetDispatch,
|
||||||
memoryPrefetcher = memoryPrefetcher,
|
memoryPrefetcher = memoryPrefetcher,
|
||||||
memoryReviewer = memoryReviewer,
|
memoryReviewer = memoryReviewer,
|
||||||
@@ -287,12 +459,14 @@ class ChatAgent(
|
|||||||
compressionThreshold = compressionThreshold,
|
compressionThreshold = compressionThreshold,
|
||||||
contextCompactor = contextCompactor,
|
contextCompactor = contextCompactor,
|
||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
skillMiner = skillMiner,
|
|
||||||
skillMiningStore = skillStore,
|
|
||||||
)
|
)
|
||||||
runBlocking {
|
runBlocking {
|
||||||
liveLock.withLock { live[conv.id] = conv }
|
liveLock.withLock { live[conv.id] = conv }
|
||||||
}
|
}
|
||||||
|
// Оповещаем ConversationAware компоненты (например, SkillMiningComponent)
|
||||||
|
// о новом разговоре — они могут подписаться на events и запустить
|
||||||
|
// фоновые задачи.
|
||||||
|
attachConversation(conv.asHandle())
|
||||||
runBlocking {
|
runBlocking {
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
CommonEvent.Agent(
|
CommonEvent.Agent(
|
||||||
@@ -309,14 +483,21 @@ class ChatAgent(
|
|||||||
val rec = mutableConversationStore.get(id) ?: return null
|
val rec = mutableConversationStore.get(id) ?: return null
|
||||||
return newConversation(rec).also {
|
return newConversation(rec).also {
|
||||||
liveLock.withLock { live[id] = it }
|
liveLock.withLock { live[id] = it }
|
||||||
|
attachConversation(it.asHandle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteConversation(id: String): Boolean {
|
override suspend fun deleteConversation(id: String): Boolean {
|
||||||
val conv = liveLock.withLock { live.remove(id) }
|
val conv = liveLock.withLock { live.remove(id) }
|
||||||
|
conv?.let { detachConversation(it.asHandle()) }
|
||||||
conv?.close()
|
conv?.close()
|
||||||
val ok = mutableConversationStore.delete(id)
|
val ok = mutableConversationStore.delete(id)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
// Каскад — здесь, потому что владелец lifecycle диалога
|
||||||
|
// (ChatAgent) имеет доступ ко всем трём store'ам, а каждый
|
||||||
|
// отдельный store должен знать только про свою таблицу.
|
||||||
|
messageStore.clear(id)
|
||||||
|
workingMemoryStore.clear(id)
|
||||||
val event = AgentEvent.Deleted(date = now(), id = id)
|
val event = AgentEvent.Deleted(date = now(), id = id)
|
||||||
eventStore.append(CommonEvent.Agent(date = now(), event = event))
|
eventStore.append(CommonEvent.Agent(date = now(), event = event))
|
||||||
}
|
}
|
||||||
@@ -339,7 +520,9 @@ class ChatAgent(
|
|||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
tools = allTools,
|
systemPromptResolver = { buildRuntimeSystemPrompt() },
|
||||||
|
toolsResolver = { collectTools() },
|
||||||
|
tools = collectTools(),
|
||||||
toolsetDispatch = toolsetDispatch,
|
toolsetDispatch = toolsetDispatch,
|
||||||
memoryPrefetcher = memoryPrefetcher,
|
memoryPrefetcher = memoryPrefetcher,
|
||||||
memoryReviewer = memoryReviewer,
|
memoryReviewer = memoryReviewer,
|
||||||
@@ -348,17 +531,23 @@ class ChatAgent(
|
|||||||
compressionThreshold = compressionThreshold,
|
compressionThreshold = compressionThreshold,
|
||||||
contextCompactor = contextCompactor,
|
contextCompactor = contextCompactor,
|
||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
skillMiner = skillMiner,
|
|
||||||
skillMiningStore = skillStore,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
liveLock.withLock {
|
liveLock.withLock {
|
||||||
live.values.forEach { it.close() }
|
live.values.forEach { it.close() }
|
||||||
|
live.values.forEach { detachConversation(it.asHandle()) }
|
||||||
live.clear()
|
live.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Снимаем компоненты в обратном порядке, чтобы каждый успел закрыть свои ресурсы.
|
||||||
|
// Копия `components` нужна на случай если uninstall модифицирует список.
|
||||||
|
val snapshot = components.toList()
|
||||||
|
for (i in snapshot.indices.reversed()) {
|
||||||
|
runCatching { snapshot[i].uninstall(this) }
|
||||||
|
}
|
||||||
|
components.clear()
|
||||||
runCatching { llm.close() }
|
runCatching { llm.close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,28 +564,17 @@ class ChatAgent(
|
|||||||
*/
|
*/
|
||||||
internal fun buildSystemPrompt(
|
internal fun buildSystemPrompt(
|
||||||
base: String,
|
base: String,
|
||||||
skills: SkillCatalog,
|
|
||||||
memoryEnabled: Boolean,
|
memoryEnabled: Boolean,
|
||||||
soulBody: String? = null,
|
|
||||||
reflections: List<Reflection> = emptyList(),
|
reflections: List<Reflection> = emptyList(),
|
||||||
toolsetSection: String? = null,
|
|
||||||
): String {
|
): String {
|
||||||
val trimmedBase = base.trimEnd()
|
val trimmedBase = base.trimEnd()
|
||||||
val skillsSection = skills.renderSystemPromptSection()
|
val withMemory = if (memoryEnabled) trimmedBase + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else trimmedBase
|
||||||
val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection
|
|
||||||
val withMemory = if (memoryEnabled) withSkills + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else withSkills
|
|
||||||
val withReflections = if (reflections.isNotEmpty()) {
|
val withReflections = if (reflections.isNotEmpty()) {
|
||||||
withMemory + "\n\n" + renderReflectionsSection(reflections)
|
withMemory + "\n\n" + renderReflectionsSection(reflections)
|
||||||
} else {
|
} else {
|
||||||
withMemory
|
withMemory
|
||||||
}
|
}
|
||||||
val withToolsets = if (!toolsetSection.isNullOrBlank()) {
|
return withReflections
|
||||||
withReflections + "\n\n" + toolsetSection
|
|
||||||
} else {
|
|
||||||
withReflections
|
|
||||||
}
|
|
||||||
val trimmedSoul = soulBody?.trim()
|
|
||||||
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withToolsets else withToolsets
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ package pw.binom.agentik.standalone.agent
|
|||||||
/**
|
/**
|
||||||
* Историческое имя класса. До 2025-Q4 разбиения god-class а на компоненты
|
* Историческое имя класса. До 2025-Q4 разбиения god-class а на компоненты
|
||||||
* (`ConversationState`, `ConversationEvents`, `ContextBuilder`,
|
* (`ConversationState`, `ConversationEvents`, `ContextBuilder`,
|
||||||
* `CompactionCoordinator`, `ToolDispatcher`, `BackgroundScheduler`) вся
|
* `CompactionCoordinator`, `ToolDispatcher`, `ReflectionScheduler`) вся
|
||||||
* логика жила в `class ChatConversation` здесь, ~1400 строк.
|
* логика жила в `class ChatConversation` здесь, ~1400 строк.
|
||||||
*
|
*
|
||||||
* После рефактора — реализация переехала в [ConversationLoop] (этот же пакет).
|
* После рефактора — реализация переехала в [ConversationLoop] (этот же пакет).
|
||||||
|
|||||||
+38
-22
@@ -18,6 +18,7 @@ import pw.binom.litert.LiteMessage
|
|||||||
import pw.binom.litert.LiteRole
|
import pw.binom.litert.LiteRole
|
||||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||||
import pw.binom.agentik.llm.tools.SummaryTurn
|
import pw.binom.agentik.llm.tools.SummaryTurn
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
internal class CompactionCoordinator(
|
internal class CompactionCoordinator(
|
||||||
private val state: ConversationState,
|
private val state: ConversationState,
|
||||||
@@ -28,13 +29,14 @@ internal class CompactionCoordinator(
|
|||||||
private val memoryStoreForReview: MemoryStore?,
|
private val memoryStoreForReview: MemoryStore?,
|
||||||
private val workingMemory: ContextStore,
|
private val workingMemory: ContextStore,
|
||||||
private val liteLlm: LiteLlm,
|
private val liteLlm: LiteLlm,
|
||||||
|
private val events: ConversationEvents,
|
||||||
|
private val now: () -> Instant,
|
||||||
private val systemPrompt: String,
|
private val systemPrompt: String,
|
||||||
private val backgroundEvents: BackgroundEventBus,
|
|
||||||
) {
|
) {
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
private val toolsCharsCached: Int by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
private val toolsCharsCached: Int by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||||
state.tools.sumOf { it.tool.describe().length }
|
state.tools.sumOf { it.describe().length }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun compactPreTurnIfNeeded(): Boolean = compactPreTurn(force = false)
|
suspend fun compactPreTurnIfNeeded(): Boolean = compactPreTurn(force = false)
|
||||||
@@ -66,11 +68,7 @@ internal class CompactionCoordinator(
|
|||||||
}
|
}
|
||||||
if (toCompact.isEmpty()) return false
|
if (toCompact.isEmpty()) return false
|
||||||
|
|
||||||
// Emit CompactionEvent BEFORE deletion. BackgroundScheduler может trigger'ить
|
events.tryEmit(pw.binom.agentik.outbox.Event.CompactionTriggered(date = now(), conversationId = state.id, turnsCompacted = toCompact.size))
|
||||||
// skill mining на основе turnsToDelete (есть контент — есть что майнить).
|
|
||||||
backgroundEvents.tryEmit(
|
|
||||||
CompactionEvent.Triggered(turnsToDelete = toCompact.size, conversationId = state.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
val turns = toCompact.mapNotNull { row ->
|
val turns = toCompact.mapNotNull { row ->
|
||||||
when (val e = row.entry) {
|
when (val e = row.entry) {
|
||||||
@@ -147,7 +145,14 @@ internal class CompactionCoordinator(
|
|||||||
val dropFrom = toCompact.first().orderIdx
|
val dropFrom = toCompact.first().orderIdx
|
||||||
workingMemory.compact(dropFromOrderIdx = dropFrom, conversationId = state.id, summaryText = summaryText)
|
workingMemory.compact(dropFromOrderIdx = dropFrom, conversationId = state.id, summaryText = summaryText)
|
||||||
|
|
||||||
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
|
// После компакта пересборка LiteConversation не нужна: litert ≥13
|
||||||
|
// умеет replaceHistory() — синхронизируем движковое состояние с актуальной
|
||||||
|
// working memory, не теряя LiteRT-LM session. На OpenAI replaceHistory тоже
|
||||||
|
// лучше (stateless — просто подмена списка).
|
||||||
|
state.liteConvRef.get()?.let { lc ->
|
||||||
|
val newHistory = buildHistoryLiteMessages(excludeUserSourceId = null)
|
||||||
|
runCatching { lc.replaceHistory(newHistory) }
|
||||||
|
}
|
||||||
|
|
||||||
val after = estimateTokens(
|
val after = estimateTokens(
|
||||||
systemText = systemText,
|
systemText = systemText,
|
||||||
@@ -189,7 +194,29 @@ internal class CompactionCoordinator(
|
|||||||
): LiteConversation {
|
): LiteConversation {
|
||||||
state.liteConvRef.get()?.let { return it }
|
state.liteConvRef.get()?.let { return it }
|
||||||
|
|
||||||
val pastTurns: List<LiteMessage> = if (state.isTemporal) emptyList() else workingMemory.list(state.id)
|
val pastTurns = buildHistoryLiteMessages(excludeUserSourceId)
|
||||||
|
|
||||||
|
val capped = if (pastTurns.size > MAX_SEEDED_MESSAGES) pastTurns.takeLast(MAX_SEEDED_MESSAGES) else pastTurns
|
||||||
|
|
||||||
|
val config = LiteConversationConfig(
|
||||||
|
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
|
||||||
|
initialMessages = capped,
|
||||||
|
tools = state.tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
return liteLlm.createConversation(config).also { state.liteConvRef.set(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перевод [WorkingMemoryRow] в формат [LiteMessage] для LiteConversation.
|
||||||
|
* Используется и при первом create, и при [LiteConversation.replaceHistory]
|
||||||
|
* после компакта (см. [compactPreTurn]).
|
||||||
|
*/
|
||||||
|
private suspend fun buildHistoryLiteMessages(
|
||||||
|
excludeUserSourceId: String?,
|
||||||
|
): List<LiteMessage> {
|
||||||
|
if (state.isTemporal) return emptyList()
|
||||||
|
return workingMemory.list(state.id)
|
||||||
.filter { row ->
|
.filter { row ->
|
||||||
val isRelevant = row.entry is WorkingMemoryEntry.User
|
val isRelevant = row.entry is WorkingMemoryEntry.User
|
||||||
|| row.entry is WorkingMemoryEntry.Assistant
|
|| row.entry is WorkingMemoryEntry.Assistant
|
||||||
@@ -199,7 +226,7 @@ internal class CompactionCoordinator(
|
|||||||
}
|
}
|
||||||
.mapNotNull { row ->
|
.mapNotNull { row ->
|
||||||
val e: WorkingMemoryEntry = row.entry
|
val e: WorkingMemoryEntry = row.entry
|
||||||
val msg: LiteMessage? = when (e) {
|
when (e) {
|
||||||
is WorkingMemoryEntry.User -> LiteMessage(
|
is WorkingMemoryEntry.User -> LiteMessage(
|
||||||
LiteRole.USER,
|
LiteRole.USER,
|
||||||
applyContextPrefix(e.content.toLiteContents(), e.context),
|
applyContextPrefix(e.content.toLiteContents(), e.context),
|
||||||
@@ -217,24 +244,13 @@ internal class CompactionCoordinator(
|
|||||||
)
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val capped = if (pastTurns.size > MAX_SEEDED_MESSAGES) pastTurns.takeLast(MAX_SEEDED_MESSAGES) else pastTurns
|
|
||||||
|
|
||||||
val config = LiteConversationConfig(
|
|
||||||
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
|
|
||||||
initialMessages = capped,
|
|
||||||
tools = state.tools.map { it.tool },
|
|
||||||
)
|
|
||||||
|
|
||||||
return liteLlm.createConversation(config).also { state.liteConvRef.set(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEEP_RECENT_TURNS = 4
|
private const val KEEP_RECENT_TURNS = 4
|
||||||
private const val MAX_SEEDED_MESSAGES = 50
|
private const val MAX_SEEDED_MESSAGES = 50
|
||||||
/** Минимум удаляемых ходов чтобы BackgroundScheduler trigger'ил skill mining. */
|
/** Минимум удаляемых ходов чтобы SkillMiningComponent trigger'ил skill mining. */
|
||||||
private const val MIN_COMPACTION_FOR_MINING = 10
|
private const val MIN_COMPACTION_FOR_MINING = 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-56
@@ -12,9 +12,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonNull
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
@@ -27,7 +25,6 @@ import pw.binom.agentik.outbox.Event as ProtoEvent
|
|||||||
import pw.binom.agentik.reflection.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.proto.Message as ProtoMessage
|
import pw.binom.agentik.proto.Message as ProtoMessage
|
||||||
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
||||||
import pw.binom.agentik.skills.SkillStore
|
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.journal.Content
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.journal.MutableConversationStore
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
@@ -47,9 +44,7 @@ import pw.binom.litert.LiteToolCall
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import pw.binom.agentik.llm.tools.LlmReflector
|
import pw.binom.agentik.llm.tools.LlmReflector
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
|
||||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
class ConversationLoop(
|
class ConversationLoop(
|
||||||
record: ConversationRecord,
|
record: ConversationRecord,
|
||||||
@@ -59,8 +54,30 @@ class ConversationLoop(
|
|||||||
private val reflectionStore: ReflectionStore?,
|
private val reflectionStore: ReflectionStore?,
|
||||||
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
|
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
|
||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
|
/**
|
||||||
|
* Базовый системный промпт, который задаётся беседе при создании.
|
||||||
|
* В runtime может перетираться через [systemPromptResolver] перед каждым
|
||||||
|
* `sendStreamContents` (LiteRT v12 mutable setters сами делают no-op,
|
||||||
|
* если значение эквивалентно).
|
||||||
|
*/
|
||||||
private val systemPrompt: String,
|
private val systemPrompt: String,
|
||||||
private val tools: List<NamedTool> = emptyList(),
|
/**
|
||||||
|
* Лямбда, которая возвращает актуальный системный промпт в момент
|
||||||
|
* каждого `sendStreamContents`. По дефолту — возвращает [systemPrompt]
|
||||||
|
* без изменений. Production-конфигурация (ChatAgent) подменяет её на
|
||||||
|
* `{ agent.buildRuntimeSystemPrompt() }`, что добавляет к базе динамические
|
||||||
|
* секции от установленных компонент (`systemProviders`).
|
||||||
|
*/
|
||||||
|
internal var systemPromptResolver: () -> String = { systemPrompt },
|
||||||
|
private val tools: List<LiteTool> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Лямбда, которая возвращает актуальный список тулов в момент каждого
|
||||||
|
* `sendStreamContents`. По дефолту — фиксированный [tools] снимок.
|
||||||
|
* Production-конфигурация (ChatAgent) подменяет её на
|
||||||
|
* `{ collectTools() }` — добавляет/убирает тулы от `toolProviders`
|
||||||
|
* компонент на лету.
|
||||||
|
*/
|
||||||
|
internal var toolsResolver: () -> List<LiteTool> = { tools },
|
||||||
private val toolsetDispatch: ToolsetDispatchPolicy? = null,
|
private val toolsetDispatch: ToolsetDispatchPolicy? = null,
|
||||||
private val memoryPrefetcher: MemoryPrefetcher? = null,
|
private val memoryPrefetcher: MemoryPrefetcher? = null,
|
||||||
private val memoryReviewer: MemoryReviewer? = null,
|
private val memoryReviewer: MemoryReviewer? = null,
|
||||||
@@ -69,8 +86,6 @@ class ConversationLoop(
|
|||||||
private val compressionThreshold: Double = 0.8,
|
private val compressionThreshold: Double = 0.8,
|
||||||
private val contextCompactor: ContextCompactor? = null,
|
private val contextCompactor: ContextCompactor? = null,
|
||||||
private val reflector: LlmReflector? = null,
|
private val reflector: LlmReflector? = null,
|
||||||
private val skillMiner: SkillMiner? = null,
|
|
||||||
private val skillMiningStore: SkillStore? = null,
|
|
||||||
) : ProtoConversation, AutoCloseable {
|
) : ProtoConversation, AutoCloseable {
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
@@ -90,10 +105,7 @@ class ConversationLoop(
|
|||||||
conversationId = state.id,
|
conversationId = state.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */
|
private val toolsByName: MutableMap<String, LiteTool> = tools.associateBy { it.name }.toMutableMap()
|
||||||
private val backgroundEvents = BackgroundEventBus()
|
|
||||||
|
|
||||||
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
|
|
||||||
|
|
||||||
private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher)
|
private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher)
|
||||||
|
|
||||||
@@ -107,33 +119,29 @@ class ConversationLoop(
|
|||||||
workingMemory = workingMemoryStore,
|
workingMemory = workingMemoryStore,
|
||||||
liteLlm = llm,
|
liteLlm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
backgroundEvents = backgroundEvents,
|
events = events,
|
||||||
|
now = ::now,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val toolDispatcher = ToolDispatcher(
|
private val toolDispatcher = ToolDispatcher(
|
||||||
state = state,
|
state = state,
|
||||||
messageStore = messageStore,
|
messageStore = messageStore,
|
||||||
events = events,
|
events = events,
|
||||||
backgroundEvents = backgroundEvents,
|
|
||||||
toolsByName = toolsByName,
|
toolsByName = toolsByName,
|
||||||
toolsetDispatch = toolsetDispatch,
|
toolsetDispatch = toolsetDispatch,
|
||||||
newId = ::newId,
|
newId = ::newId,
|
||||||
encodeArgsJson = ::encodeArgsJson,
|
|
||||||
now = ::now,
|
now = ::now,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val backgroundScheduler = BackgroundScheduler(
|
private val reflectionScheduler = ReflectionScheduler(
|
||||||
state = state,
|
state = state,
|
||||||
workingMemory = workingMemoryStore,
|
workingMemory = workingMemoryStore,
|
||||||
config = BackgroundConfig(
|
config = ReflectionConfig(
|
||||||
memoryReviewer = memoryReviewer,
|
|
||||||
memoryStore = memoryStoreForReview,
|
|
||||||
reflectionStore = reflectionStore,
|
|
||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
skillMiner = skillMiner,
|
reflectionStore = reflectionStore,
|
||||||
skillMiningStore = skillMiningStore,
|
|
||||||
),
|
),
|
||||||
backgroundEvents = backgroundEvents,
|
eventStore = eventStore,
|
||||||
|
conversationIdProvider = { id },
|
||||||
).also { it.start(agentScope) }
|
).also { it.start(agentScope) }
|
||||||
|
|
||||||
override val id: String get() = state.id
|
override val id: String get() = state.id
|
||||||
@@ -226,10 +234,12 @@ class ConversationLoop(
|
|||||||
override fun close() {
|
override fun close() {
|
||||||
if (state.isClosed) return
|
if (state.isClosed) return
|
||||||
state.markClosed()
|
state.markClosed()
|
||||||
// Emit Closing event BEFORE agentScope.cancel() — BackgroundScheduler's подписка
|
// Emit Closing event BEFORE agentScope.cancel() — ReflectionScheduler's подписка
|
||||||
// ловит это и делает final reflection + skill mining (last chance вытащить insights).
|
// ловит это и делает final reflection (last chance вытащить insights).
|
||||||
// После cancel() подписка умерла бы.
|
// После cancel() подписка умерла бы.
|
||||||
backgroundEvents.tryEmit(ConversationLifecycleEvent.Closing(conversationId = id))
|
runCatching {
|
||||||
|
events.tryEmit(pw.binom.agentik.outbox.Event.ConversationClosing(date = now(), conversationId = id))
|
||||||
|
}
|
||||||
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
|
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
|
||||||
runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
|
runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
|
||||||
agentScope.cancel()
|
agentScope.cancel()
|
||||||
@@ -237,10 +247,6 @@ class ConversationLoop(
|
|||||||
|
|
||||||
suspend fun forceCompactNow(): Boolean = compactor.forceCompactNow()
|
suspend fun forceCompactNow(): Boolean = compactor.forceCompactNow()
|
||||||
|
|
||||||
internal fun registerToolForTest(name: String, tool: LiteTool) {
|
|
||||||
toolDispatcher.registerToolForTest(name, tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
|
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
|
||||||
val wasInterruptedAtEntry = interrupted.get()
|
val wasInterruptedAtEntry = interrupted.get()
|
||||||
if (!state.isTemporal) {
|
if (!state.isTemporal) {
|
||||||
@@ -275,7 +281,7 @@ class ConversationLoop(
|
|||||||
|
|
||||||
val conv = try {
|
val conv = try {
|
||||||
compactor.getOrCreateLiteConversation(
|
compactor.getOrCreateLiteConversation(
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPromptResolver(),
|
||||||
excludeUserSourceId = if (state.isTemporal) null else userRecord.id,
|
excludeUserSourceId = if (state.isTemporal) null else userRecord.id,
|
||||||
)
|
)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -307,6 +313,10 @@ class ConversationLoop(
|
|||||||
val collectedCalls = mutableListOf<LiteToolCall>()
|
val collectedCalls = mutableListOf<LiteToolCall>()
|
||||||
if (pendingParts != null) {
|
if (pendingParts != null) {
|
||||||
val lc = state.liteConvRef.get() ?: return
|
val lc = state.liteConvRef.get() ?: return
|
||||||
|
// Hot-swap перед каждым sendStreamContents — v12 setter no-op
|
||||||
|
// если значение эквивалентно текущему (см. LiteConversation.systemInstruction).
|
||||||
|
lc.systemInstruction = systemPromptResolver()
|
||||||
|
lc.tools = toolsResolver()
|
||||||
try {
|
try {
|
||||||
lc.sendStreamContents(pendingParts).collect { delta ->
|
lc.sendStreamContents(pendingParts).collect { delta ->
|
||||||
if (delta.text.isNotEmpty()) {
|
if (delta.text.isNotEmpty()) {
|
||||||
@@ -359,6 +369,10 @@ class ConversationLoop(
|
|||||||
try {
|
try {
|
||||||
val collectedPostTool = mutableListOf<LiteToolCall>()
|
val collectedPostTool = mutableListOf<LiteToolCall>()
|
||||||
val lc = state.liteConvRef.get() ?: return
|
val lc = state.liteConvRef.get() ?: return
|
||||||
|
// Hot-swap перед follow-up — на случай если компонент
|
||||||
|
// изменил toolset/soul/skills между turn'ами.
|
||||||
|
lc.systemInstruction = systemPromptResolver()
|
||||||
|
lc.tools = toolsResolver()
|
||||||
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
|
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
|
||||||
if (followUp.text.isNotEmpty()) {
|
if (followUp.text.isNotEmpty()) {
|
||||||
reply.append(followUp.text)
|
reply.append(followUp.text)
|
||||||
@@ -452,9 +466,9 @@ class ConversationLoop(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus
|
// ReflectionScheduler is event-driven — подписан на outbox
|
||||||
// (compaction/lifecycle/tool-failure events). Никаких interval-based
|
// (Event.ConversationClosing / Event.ToolFailed). Никаких
|
||||||
// вызовов сюда больше не идёт. См. BackgroundScheduler.kt.
|
// interval-based вызовов сюда больше не идёт. См. ReflectionScheduler.kt.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,28 +506,6 @@ class ConversationLoop(
|
|||||||
|
|
||||||
private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix)
|
private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix)
|
||||||
|
|
||||||
private fun encodeArgsJson(arguments: Map<String, Any?>): String {
|
|
||||||
val el = JsonElement.serializer()
|
|
||||||
val obj = buildJsonObject {
|
|
||||||
arguments.forEach { (k, v) -> put(k, v.toJsonElement()) }
|
|
||||||
}
|
|
||||||
return Json.encodeToString(el, obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Any?.toJsonElement(): JsonElement = when (this) {
|
|
||||||
null -> JsonNull
|
|
||||||
is Boolean -> JsonPrimitive(this)
|
|
||||||
is Number -> JsonPrimitive(this)
|
|
||||||
is String -> JsonPrimitive(this)
|
|
||||||
is Map<*, *> -> buildJsonObject {
|
|
||||||
this@toJsonElement.forEach { (k, v) ->
|
|
||||||
put(k.toString(), v.toJsonElement())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is List<*> -> JsonArray(this.map { it.toJsonElement() })
|
|
||||||
else -> JsonPrimitive(toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_TOOL_LOOPS = 16
|
private const val MAX_TOOL_LOOPS = 16
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -3,12 +3,12 @@ package pw.binom.agentik.standalone.agent
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
internal class ConversationState(
|
internal class ConversationState(
|
||||||
initialRecord: ConversationRecord,
|
initialRecord: ConversationRecord,
|
||||||
val tools: List<NamedTool>,
|
val tools: List<LiteTool>,
|
||||||
val agentScope: CoroutineScope,
|
val agentScope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
|
|||||||
+31
-88
@@ -2,57 +2,57 @@ package pw.binom.agentik.standalone.agent
|
|||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import pw.binom.agentik.memory.MemoryReviewer
|
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
|
||||||
import pw.binom.agentik.skills.SkillStore
|
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.journal.Content
|
||||||
import pw.binom.agentik.reflection.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.llm.tools.LlmReflector
|
import pw.binom.agentik.llm.tools.LlmReflector
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Background work подписчик на [BackgroundEventBus]. Заменяет старую interval-based
|
* Event-driven reflection trigger. Подписан на [OutboxStore] и пишет
|
||||||
* логику (`maybeScheduleReview/Reflection/SkillMining` с `userTurnCount % N == 0`).
|
* в [ReflectionStore] когда:
|
||||||
*
|
*
|
||||||
* Подписки:
|
* - `Event.ConversationClosing` для нашего `conversationId` — финальная
|
||||||
* - [ConversationLifecycleEvent.Closing] → финальный reflection + skill mining
|
* рефлексия перед закрытием. Skill mining на Closing уже делает
|
||||||
* перед закрытием conversation (last chance вытащить insights).
|
* [pw.binom.agentik.skill.mining.SkillMiningComponent].
|
||||||
* - [CompactionEvent.Triggered] → skill mining если `turnsToDelete > MIN_COMPACTION_FOR_MINING`.
|
* - `Event.ToolFailed` для нашего `conversationId` — накопительно:
|
||||||
* Review уже сделан внутри CompactionCoordinator (`reviewPreCompaction`) — не дублируем.
|
* 2+ фейлов за 60 сек запускают рефлексию (что-то идёт не так —
|
||||||
* - [ToolCallEvent.Failed] (накопительно) → reflection если 2+ фейлов в окне 60 сек
|
* самоанализ полезен).
|
||||||
* (что-то пошло не так — самоанализ полезен).
|
|
||||||
*
|
*
|
||||||
* НЕ текстовая инспекция, НЕ regex, НЕ interval-polling. Только структурные
|
* НЕ текстовая инспекция, НЕ regex, НЕ interval-polling. Только структурные
|
||||||
* события с явным семантическим смыслом.
|
* события с явным семантическим смыслом.
|
||||||
|
*
|
||||||
|
* Подписка идёт через outbox (а не через per-conversation BackgroundEventBus
|
||||||
|
* который был раньше): outbox — единый канал для всех событий (как клиентских,
|
||||||
|
* так и внутренних), persistent tail с TTL работает из коробки, а клиенты
|
||||||
|
* по тому же потоку могут самостоятельно видеть/логировать [Event.ToolFailed]
|
||||||
|
* без скрытой телеметрии.
|
||||||
*/
|
*/
|
||||||
internal data class BackgroundConfig(
|
internal data class ReflectionConfig(
|
||||||
val memoryReviewer: MemoryReviewer?,
|
|
||||||
val memoryStore: MemoryStore?,
|
|
||||||
val reflectionStore: ReflectionStore?,
|
|
||||||
val reflector: LlmReflector?,
|
val reflector: LlmReflector?,
|
||||||
val skillMiner: SkillMiner?,
|
val reflectionStore: ReflectionStore?,
|
||||||
val skillMiningStore: SkillStore?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
internal class BackgroundScheduler(
|
internal class ReflectionScheduler(
|
||||||
private val state: ConversationState,
|
private val state: ConversationState,
|
||||||
private val workingMemory: ContextStore,
|
private val workingMemory: ContextStore,
|
||||||
private val config: BackgroundConfig,
|
private val config: ReflectionConfig,
|
||||||
private val backgroundEvents: BackgroundEventBus,
|
private val eventStore: OutboxStore,
|
||||||
|
private val conversationIdProvider: () -> String,
|
||||||
) {
|
) {
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
private val lastSkillMiningAt = AtomicLong(0)
|
|
||||||
private val lastReflectionAt = AtomicLong(0)
|
private val lastReflectionAt = AtomicLong(0)
|
||||||
|
|
||||||
/** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */
|
/** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */
|
||||||
@@ -65,17 +65,14 @@ internal class BackgroundScheduler(
|
|||||||
fun start(scope: CoroutineScope) {
|
fun start(scope: CoroutineScope) {
|
||||||
if (subscriptionJob?.isActive == true) return
|
if (subscriptionJob?.isActive == true) return
|
||||||
subscriptionJob = scope.launch {
|
subscriptionJob = scope.launch {
|
||||||
// Merge all three event flows into one subscription scope. Each onEach
|
|
||||||
// returns Unit, so launchIn merges them as cold flows.
|
|
||||||
merge(
|
merge(
|
||||||
backgroundEvents.lifecycleEvents
|
eventStore.events(after = null)
|
||||||
.filterIsInstance<ConversationLifecycleEvent.Closing>()
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
|
.filter { it.event is Event.ConversationClosing && it.conversationId == conversationIdProvider() }
|
||||||
.onEach { onClosing() },
|
.onEach { onClosing() },
|
||||||
backgroundEvents.compactionEvents
|
eventStore.events(after = null)
|
||||||
.filterIsInstance<CompactionEvent.Triggered>()
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
.onEach { onCompaction(it) },
|
.filter { it.event is Event.ToolFailed && it.conversationId == conversationIdProvider() }
|
||||||
backgroundEvents.toolCallEvents
|
|
||||||
.filterIsInstance<ToolCallEvent.Failed>()
|
|
||||||
.onEach { onToolFailure() },
|
.onEach { onToolFailure() },
|
||||||
).collect {}
|
).collect {}
|
||||||
}
|
}
|
||||||
@@ -83,9 +80,7 @@ internal class BackgroundScheduler(
|
|||||||
|
|
||||||
private suspend fun onClosing() {
|
private suspend fun onClosing() {
|
||||||
if (state.isTemporal) return
|
if (state.isTemporal) return
|
||||||
val convId = state.id
|
runFinalReflection(state.id)
|
||||||
runFinalReflection(convId)
|
|
||||||
runFinalSkillMining(convId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runFinalReflection(convId: String) {
|
private fun runFinalReflection(convId: String) {
|
||||||
@@ -105,51 +100,6 @@ internal class BackgroundScheduler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runFinalSkillMining(convId: String) {
|
|
||||||
val miner = config.skillMiner ?: return
|
|
||||||
val store = config.skillMiningStore ?: return
|
|
||||||
state.agentScope.launch {
|
|
||||||
try {
|
|
||||||
val turns = recentTurns(miner.maxTurns)
|
|
||||||
if (turns.isEmpty()) return@launch
|
|
||||||
val mined = miner.mine(turns, store.catalog.skills)
|
|
||||||
for (s in mined) {
|
|
||||||
runCatching { store.upsert(s) }
|
|
||||||
.onFailure { log.warn(it) { "final skill mining upsert '${s.name}' failed: ${it.message}" } }
|
|
||||||
}
|
|
||||||
log.info { "final skill mining on close: conv=$convId turns=${turns.size} existing=${store.catalog.skills.size} mined=${mined.size}" }
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
log.warn(e) { "final skill mining failed for $convId: ${e.message}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onCompaction(event: CompactionEvent.Triggered) {
|
|
||||||
if (state.isTemporal) return
|
|
||||||
if (event.turnsToDelete < MIN_COMPACTION_FOR_MINING) return
|
|
||||||
val miner = config.skillMiner ?: return
|
|
||||||
val store = config.skillMiningStore ?: return
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
// Debounce: не чаще раза в минуту
|
|
||||||
if (now - lastSkillMiningAt.get() < MINING_DEBOUNCE_MS) return
|
|
||||||
lastSkillMiningAt.set(now)
|
|
||||||
val convId = event.conversationId
|
|
||||||
state.agentScope.launch {
|
|
||||||
try {
|
|
||||||
val turns = recentTurns(miner.maxTurns)
|
|
||||||
if (turns.isEmpty()) return@launch
|
|
||||||
val mined = miner.mine(turns, store.catalog.skills)
|
|
||||||
for (s in mined) {
|
|
||||||
runCatching { store.upsert(s) }
|
|
||||||
.onFailure { log.warn(it) { "compaction skill mining upsert '${s.name}' failed: ${it.message}" } }
|
|
||||||
}
|
|
||||||
log.info { "compaction skill mining: conv=$convId turnsToDelete=${event.turnsToDelete} mined=${mined.size}" }
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
log.warn(e) { "compaction skill mining failed for $convId: ${e.message}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onToolFailure() {
|
private fun onToolFailure() {
|
||||||
val reflector = config.reflector ?: return
|
val reflector = config.reflector ?: return
|
||||||
if (state.isTemporal) return
|
if (state.isTemporal) return
|
||||||
@@ -158,21 +108,18 @@ internal class BackgroundScheduler(
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val shouldReflect = synchronized(toolFailuresLock) {
|
val shouldReflect = synchronized(toolFailuresLock) {
|
||||||
toolFailures.add(now)
|
toolFailures.add(now)
|
||||||
// Trim old failures outside the window
|
|
||||||
val cutoff = now - FAILURE_WINDOW_MS
|
val cutoff = now - FAILURE_WINDOW_MS
|
||||||
toolFailures.removeAll { it < cutoff }
|
toolFailures.removeAll { it < cutoff }
|
||||||
toolFailures.size >= FAILURE_THRESHOLD
|
toolFailures.size >= FAILURE_THRESHOLD
|
||||||
}
|
}
|
||||||
if (!shouldReflect) return
|
if (!shouldReflect) return
|
||||||
|
|
||||||
// Debounce reflection globally (не чаще раза в 5 мин)
|
|
||||||
if (now - lastReflectionAt.get() < REFLECTION_DEBOUNCE_MS) {
|
if (now - lastReflectionAt.get() < REFLECTION_DEBOUNCE_MS) {
|
||||||
log.debug { "reflection debounced: ${toolFailures.size} failures accumulated but reflection fired recently" }
|
log.debug { "reflection debounced: ${toolFailures.size} failures accumulated but reflection fired recently" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastReflectionAt.set(now)
|
lastReflectionAt.set(now)
|
||||||
|
|
||||||
// Clear failure window — fresh accounting period
|
|
||||||
synchronized(toolFailuresLock) { toolFailures.clear() }
|
synchronized(toolFailuresLock) { toolFailures.clear() }
|
||||||
|
|
||||||
val convId = state.id
|
val convId = state.id
|
||||||
@@ -212,10 +159,6 @@ internal class BackgroundScheduler(
|
|||||||
filterIsInstance<Content.Text>().joinToString("\n") { it.body }
|
filterIsInstance<Content.Text>().joinToString("\n") { it.body }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Минимум ходов, удаляемых compaction'ом, чтобы trigger'ить skill mining. */
|
|
||||||
private const val MIN_COMPACTION_FOR_MINING = 10
|
|
||||||
/** Дебаунс skill mining между запусками. */
|
|
||||||
private const val MINING_DEBOUNCE_MS = 60_000L
|
|
||||||
/** Debounce reflection между запусками (накопительный, не per-failure). */
|
/** Debounce reflection между запусками (накопительный, не per-failure). */
|
||||||
private const val REFLECTION_DEBOUNCE_MS = 300_000L
|
private const val REFLECTION_DEBOUNCE_MS = 300_000L
|
||||||
/** Сколько tool-failures в окне должно накопиться чтобы trigger reflection. */
|
/** Сколько tool-failures в окне должно накопиться чтобы trigger reflection. */
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import pw.binom.agentik.skills.SkillStore
|
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style).
|
|
||||||
*
|
|
||||||
* Возвращает два NamedTool'а:
|
|
||||||
* - `skill_save(name, description, body)` — создать или обновить скил;
|
|
||||||
* - `skill_delete(name)` — архивировать скил.
|
|
||||||
*
|
|
||||||
* Оба оборачивают [SkillStore]; агент может дёргать их в любой момент,
|
|
||||||
* не только во время review-loop.
|
|
||||||
*/
|
|
||||||
object SkillToolsFactory {
|
|
||||||
|
|
||||||
fun create(store: SkillStore): List<NamedTool> = listOf(
|
|
||||||
NamedTool("skill_save", SkillSaveTool(store)),
|
|
||||||
NamedTool("skill_delete", SkillDeleteTool(store)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+16
-15
@@ -12,17 +12,14 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
|||||||
import pw.binom.litert.LiteToolCall
|
import pw.binom.litert.LiteToolCall
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
internal class ToolDispatcher(
|
internal class ToolDispatcher(
|
||||||
private val state: ConversationState,
|
private val state: ConversationState,
|
||||||
private val messageStore: MutableJournalStore,
|
private val messageStore: MutableJournalStore,
|
||||||
private val events: ConversationEvents,
|
private val events: ConversationEvents,
|
||||||
private val backgroundEvents: BackgroundEventBus,
|
private val toolsByName: MutableMap<String, LiteTool>,
|
||||||
private val toolsByName: MutableMap<String, NamedTool>,
|
|
||||||
private val toolsetDispatch: ToolsetDispatchPolicy?,
|
private val toolsetDispatch: ToolsetDispatchPolicy?,
|
||||||
private val newId: (String) -> String,
|
private val newId: (String) -> String,
|
||||||
private val encodeArgsJson: (Map<String, Any?>) -> String,
|
|
||||||
private val now: () -> Instant,
|
private val now: () -> Instant,
|
||||||
) {
|
) {
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
@@ -32,14 +29,11 @@ internal class ToolDispatcher(
|
|||||||
|
|
||||||
val currentToolJob: Job? get() = _currentToolJob
|
val currentToolJob: Job? get() = _currentToolJob
|
||||||
|
|
||||||
internal fun registerToolForTest(name: String, tool: LiteTool) {
|
|
||||||
toolsByName[name] = NamedTool(name = name, tool = tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange {
|
suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange {
|
||||||
val callId = newId("tc")
|
val callId = newId("tc")
|
||||||
val resultId = newId("tr")
|
val resultId = newId("tr")
|
||||||
val argsJson = encodeArgsJson(call.arguments)
|
// v10: LiteToolCall.arguments — уже raw JSON-строка (раньше была Map<String, Any?>).
|
||||||
|
val argsJson = call.arguments
|
||||||
val nowTs = now()
|
val nowTs = now()
|
||||||
val startMs = System.currentTimeMillis()
|
val startMs = System.currentTimeMillis()
|
||||||
|
|
||||||
@@ -65,7 +59,7 @@ internal class ToolDispatcher(
|
|||||||
log.warn { "tool '${call.name}' requested but not registered" }
|
log.warn { "tool '${call.name}' requested but not registered" }
|
||||||
"[tool not found: ${call.name}]"
|
"[tool not found: ${call.name}]"
|
||||||
} else {
|
} else {
|
||||||
t.tool.invoke(argsJson)
|
t.invoke(argsJson)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val d = toolsetDispatch
|
val d = toolsetDispatch
|
||||||
@@ -95,13 +89,20 @@ internal class ToolDispatcher(
|
|||||||
val resultAt = now()
|
val resultAt = now()
|
||||||
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
|
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
|
||||||
|
|
||||||
// Эмитим background event — другие компоненты (BackgroundScheduler)
|
// Только реальные падения тула попадают в outbox как background-event
|
||||||
// решают, делать ли что-то. Cancellation = not a failure (не эмитим Failed).
|
// (reflection-подобные потребители). Cancellation — not a failure,
|
||||||
|
// успех — тоже без фонового события (ToolResult уже всё сказал).
|
||||||
val durationMs = System.currentTimeMillis() - startMs
|
val durationMs = System.currentTimeMillis() - startMs
|
||||||
if (failureError != null) {
|
if (failureError != null) {
|
||||||
backgroundEvents.tryEmit(ToolCallEvent.Failed(toolName = call.name, error = failureError))
|
events.tryEmit(
|
||||||
} else if (resultText != "[cancelled by user]") {
|
ProtoEvent.ToolFailed(
|
||||||
backgroundEvents.tryEmit(ToolCallEvent.Succeeded(toolName = call.name, durationMs = durationMs))
|
date = resultAt,
|
||||||
|
toolCallId = callId,
|
||||||
|
toolName = call.name,
|
||||||
|
message = failureError,
|
||||||
|
durationMs = durationMs,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isTemporal) {
|
if (!state.isTemporal) {
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import pw.binom.agentik.agent.Component
|
||||||
|
import pw.binom.agentik.agent.MutableAgent
|
||||||
|
import pw.binom.agentik.agent.SystemPromptProvider
|
||||||
|
import pw.binom.agentik.agent.ToolProvider
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemorySystemGuidance
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключает память агенту:
|
||||||
|
* - Тулы memory_save / memory_read / memory_list / memory_delete —
|
||||||
|
* через [MemoryToolProvider].
|
||||||
|
* - Секция системного промпта с категориями и форматом записи —
|
||||||
|
* через [MemorySystemProvider].
|
||||||
|
*
|
||||||
|
* Память опциональна — если [MemoryStore] == null, компонент не регистрирует
|
||||||
|
* ни провайдеры, ни system-секцию. Поведение идентично прошлому (когда
|
||||||
|
* `memoryStore=null` ничего не добавлялось).
|
||||||
|
*/
|
||||||
|
class MemoryComponent(
|
||||||
|
private val store: MemoryStore?,
|
||||||
|
) : Component {
|
||||||
|
|
||||||
|
private var toolProvider: MemoryToolProvider? = null
|
||||||
|
|
||||||
|
override fun install(agent: MutableAgent) {
|
||||||
|
if (store == null) return
|
||||||
|
val tp = MemoryToolProvider(store)
|
||||||
|
agent.toolProviders.add(tp)
|
||||||
|
toolProvider = tp
|
||||||
|
agent.systemProviders.add(MemorySystemProvider())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uninstall(agent: MutableAgent) {
|
||||||
|
toolProvider?.let { agent.toolProviders.remove(it) }
|
||||||
|
agent.systemProviders.removeAll { it is MemorySystemProvider }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MemoryToolProvider(
|
||||||
|
private val store: MemoryStore,
|
||||||
|
) : ToolProvider {
|
||||||
|
override fun getTools(conversationId: String): List<LiteTool> =
|
||||||
|
MemoryToolsFactory.create(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MemorySystemProvider : SystemPromptProvider {
|
||||||
|
override fun getSection(conversationId: String): String =
|
||||||
|
MemorySystemGuidance.MEMORY_GUIDANCE
|
||||||
|
}
|
||||||
+58
-142
@@ -1,16 +1,6 @@
|
|||||||
package pw.binom.agentik.standalone.agent.memory
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.add
|
|
||||||
import kotlinx.serialization.json.addJsonObject
|
|
||||||
import kotlinx.serialization.json.buildJsonArray
|
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
|
||||||
import kotlinx.serialization.json.put
|
|
||||||
import kotlinx.serialization.json.putJsonArray
|
|
||||||
import kotlinx.serialization.json.putJsonObject
|
|
||||||
import pw.binom.agentik.memory.DefaultMemoryTools
|
import pw.binom.agentik.memory.DefaultMemoryTools
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
import pw.binom.agentik.memory.MemoryNote
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
@@ -19,13 +9,19 @@ import pw.binom.agentik.memory.MemorySearchResult
|
|||||||
import pw.binom.agentik.memory.MemorySource
|
import pw.binom.agentik.memory.MemorySource
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.memory.NewMemoryNote
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
|
import pw.binom.agentik.toolsets.liteToolSuspend
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
private val MemoryToolsJson = Json { ignoreUnknownKeys = true; isLenient = true }
|
private val MemoryResponseJson = kotlinx.serialization.json.Json { encodeDefaults = true }
|
||||||
private val MemoryResponseJson = Json { encodeDefaults = true }
|
|
||||||
|
private val ToolInvocationJson = kotlinx.serialization.json.Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = false
|
||||||
|
coerceInputValues = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Превращает [NewMemoryNote] (из reviewer'а) в полноценную [MemoryNote],
|
* Превращает [NewMemoryNote] (из reviewer'а) в полноценную [MemoryNote],
|
||||||
@@ -48,169 +44,89 @@ fun materializeReviewNote(n: NewMemoryNote, conversationId: String? = null): Mem
|
|||||||
/** Сериализация результатов поиска для LLM — компактный JSON. */
|
/** Сериализация результатов поиска для LLM — компактный JSON. */
|
||||||
fun serializeSearchResults(results: List<MemorySearchResult>): String {
|
fun serializeSearchResults(results: List<MemorySearchResult>): String {
|
||||||
val items = results.map { r ->
|
val items = results.map { r ->
|
||||||
buildJsonObject {
|
kotlinx.serialization.json.buildJsonObject {
|
||||||
put("id", JsonPrimitive(r.note.id))
|
put("id", kotlinx.serialization.json.JsonPrimitive(r.note.id))
|
||||||
put("category", JsonPrimitive(r.note.category.id))
|
put("category", kotlinx.serialization.json.JsonPrimitive(r.note.category.id))
|
||||||
put("score", JsonPrimitive(r.score))
|
put("score", kotlinx.serialization.json.JsonPrimitive(r.score))
|
||||||
put("content", JsonPrimitive(truncate(r.note.content, 300)))
|
put("content", kotlinx.serialization.json.JsonPrimitive(truncate(r.note.content, 300)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } })
|
return MemoryResponseJson.encodeToString(
|
||||||
|
kotlinx.serialization.json.JsonElement.serializer(),
|
||||||
|
kotlinx.serialization.json.buildJsonArray { items.forEach { add(it) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сериализация списка заметок для LLM. */
|
/** Сериализация списка заметок для LLM. */
|
||||||
fun serializeNotes(notes: List<MemoryNote>): String {
|
fun serializeNotes(notes: List<MemoryNote>): String {
|
||||||
val items = notes.map { n ->
|
val items = notes.map { n ->
|
||||||
buildJsonObject {
|
kotlinx.serialization.json.buildJsonObject {
|
||||||
put("id", JsonPrimitive(n.id))
|
put("id", kotlinx.serialization.json.JsonPrimitive(n.id))
|
||||||
put("category", JsonPrimitive(n.category.id))
|
put("category", kotlinx.serialization.json.JsonPrimitive(n.category.id))
|
||||||
put("content", JsonPrimitive(truncate(n.content, 300)))
|
put("content", kotlinx.serialization.json.JsonPrimitive(truncate(n.content, 300)))
|
||||||
put("created_at", JsonPrimitive(n.createdAt.toString()))
|
put("created_at", kotlinx.serialization.json.JsonPrimitive(n.createdAt.toString()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } })
|
return MemoryResponseJson.encodeToString(
|
||||||
|
kotlinx.serialization.json.JsonElement.serializer(),
|
||||||
|
kotlinx.serialization.json.buildJsonArray { items.forEach { add(it) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun truncate(s: String, max: Int): String =
|
private fun truncate(s: String, max: Int): String =
|
||||||
if (s.length <= max) s else s.substring(0, max) + "…"
|
if (s.length <= max) s else s.substring(0, max) + "…"
|
||||||
|
|
||||||
private fun parseArgs(s: String): JsonObject =
|
@Serializable
|
||||||
runCatching { MemoryToolsJson.parseToJsonElement(s).jsonObject }.getOrElse { JsonObject(emptyMap()) }
|
internal data class SaveArgs(val category: String, val content: String)
|
||||||
|
|
||||||
private fun objString(s: String, key: String): String? {
|
@Serializable
|
||||||
val v = parseArgs(s)[key] ?: return null
|
internal data class ReadArgs(val query: String, val top_k: Int = 5, val category: String? = null)
|
||||||
return if (v is JsonPrimitive && v.isString) v.content else v.toString().trim('"')
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun objInt(s: String, key: String): Int? = objString(s, key)?.toIntOrNull()
|
@Serializable
|
||||||
|
internal data class ListArgs(val category: String? = null, val limit: Int = 20)
|
||||||
|
|
||||||
/**
|
@Serializable
|
||||||
* Flat OpenAPI-спецификация тула (name/description/parameters) — формат,
|
internal data class DeleteArgs(val id: String)
|
||||||
* который напрямую принимает LiteRT-LM (litert-google). litert-openai
|
|
||||||
* оборачивает её в OpenAI-формат сам. [name] совпадает с именем NamedTool.
|
|
||||||
*/
|
|
||||||
private fun toolDescribe(name: String, description: String, parameters: kotlinx.serialization.json.JsonObject): String =
|
|
||||||
buildJsonObject {
|
|
||||||
put("name", name)
|
|
||||||
put("description", description)
|
|
||||||
put("parameters", parameters)
|
|
||||||
}.toString()
|
|
||||||
|
|
||||||
/** parameters-блок JSON-Schema: {"type":"object","properties":{...},"required":[...]}. */
|
|
||||||
private fun objectSchema(
|
|
||||||
properties: kotlinx.serialization.json.JsonObject,
|
|
||||||
required: List<String> = emptyList(),
|
|
||||||
): kotlinx.serialization.json.JsonObject = buildJsonObject {
|
|
||||||
put("type", "object")
|
|
||||||
put("properties", properties)
|
|
||||||
if (required.isNotEmpty()) put("required", buildJsonArray { required.forEach { add(it) } })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** memory_save(category, content) → upsert. */
|
/** memory_save(category, content) → upsert. */
|
||||||
internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
|
internal fun saveTool(store: MemoryStore): LiteTool = liteToolSuspend<SaveArgs>(
|
||||||
describeJson = toolDescribe(
|
name = "memory_save",
|
||||||
"memory_save",
|
description = DefaultMemoryTools.save.description,
|
||||||
DefaultMemoryTools.save.description,
|
|
||||||
objectSchema(
|
|
||||||
properties = buildJsonObject {
|
|
||||||
putJsonObject("category") {
|
|
||||||
put("type", "string")
|
|
||||||
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
|
|
||||||
put("description", "user | world | preference")
|
|
||||||
}
|
|
||||||
putJsonObject("content") {
|
|
||||||
put("type", "string")
|
|
||||||
put("description", "the fact to remember")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required = listOf("category", "content"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) { args ->
|
) { args ->
|
||||||
val category = objString(args, "category")?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
|
val category = runCatching { MemoryCategory.fromId(args.category) }.getOrNull()
|
||||||
?: return@SyncLiteTool """{"error":"category required"}"""
|
?: return@liteToolSuspend """{"error":"category required"}"""
|
||||||
val content = objString(args, "content")
|
if (args.content.isBlank()) return@liteToolSuspend """{"error":"content is blank"}"""
|
||||||
?: return@SyncLiteTool """{"error":"content required"}"""
|
val note = materializeReviewNote(NewMemoryNote(category, args.content))
|
||||||
if (content.isBlank()) return@SyncLiteTool """{"error":"content is blank"}"""
|
|
||||||
val note = materializeReviewNote(NewMemoryNote(category, content))
|
|
||||||
store.upsert(note)
|
store.upsert(note)
|
||||||
"""{"ok":true,"id":"${note.id}"}"""
|
"""{"ok":true,"id":"${note.id}"}"""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** memory_read(query, top_k?, category?) → search. */
|
/** memory_read(query, top_k?, category?) → search. */
|
||||||
internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
|
internal fun readTool(store: MemoryStore): LiteTool = liteToolSuspend<ReadArgs>(
|
||||||
describeJson = toolDescribe(
|
name = "memory_read",
|
||||||
"memory_read",
|
description = DefaultMemoryTools.read.description,
|
||||||
DefaultMemoryTools.read.description,
|
|
||||||
objectSchema(
|
|
||||||
properties = buildJsonObject {
|
|
||||||
putJsonObject("query") {
|
|
||||||
put("type", "string")
|
|
||||||
put("description", "free-text query")
|
|
||||||
}
|
|
||||||
putJsonObject("top_k") {
|
|
||||||
put("type", "integer")
|
|
||||||
put("description", "максимум результатов (default 5)")
|
|
||||||
}
|
|
||||||
putJsonObject("category") {
|
|
||||||
put("type", "string")
|
|
||||||
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required = listOf("query"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) { args ->
|
) { args ->
|
||||||
val query = objString(args, "query") ?: return@SyncLiteTool """{"error":"query required"}"""
|
val category = args.category?.takeIf { it.isNotBlank() }
|
||||||
val topK = objInt(args, "top_k") ?: 5
|
|
||||||
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
|
|
||||||
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
|
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
|
||||||
val results = store.search(MemorySearchQuery(query = query, topK = topK, category = category))
|
val results = store.search(MemorySearchQuery(query = args.query, topK = args.top_k, category = category))
|
||||||
serializeSearchResults(results)
|
serializeSearchResults(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** memory_list(category?, limit?) → list. */
|
/** memory_list(category?, limit?) → list. */
|
||||||
internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
|
internal fun listTool(store: MemoryStore): LiteTool = liteToolSuspend<ListArgs>(
|
||||||
describeJson = toolDescribe(
|
name = "memory_list",
|
||||||
"memory_list",
|
description = DefaultMemoryTools.list.description,
|
||||||
DefaultMemoryTools.list.description,
|
|
||||||
objectSchema(
|
|
||||||
properties = buildJsonObject {
|
|
||||||
putJsonObject("category") {
|
|
||||||
put("type", "string")
|
|
||||||
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
|
|
||||||
}
|
|
||||||
putJsonObject("limit") {
|
|
||||||
put("type", "integer")
|
|
||||||
put("description", "default 20")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) { args ->
|
) { args ->
|
||||||
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
|
val category = args.category?.takeIf { it.isNotBlank() }
|
||||||
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
|
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
|
||||||
val limit = objInt(args, "limit") ?: 20
|
val notes = store.list(category = category, limit = args.limit)
|
||||||
val notes = store.list(category = category, limit = limit)
|
|
||||||
serializeNotes(notes)
|
serializeNotes(notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** memory_delete(id) → delete. */
|
/** memory_delete(id) → delete. */
|
||||||
internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
|
internal fun deleteTool(store: MemoryStore): LiteTool = liteToolSuspend<DeleteArgs>(
|
||||||
describeJson = toolDescribe(
|
name = "memory_delete",
|
||||||
"memory_delete",
|
description = DefaultMemoryTools.delete.description,
|
||||||
DefaultMemoryTools.delete.description,
|
|
||||||
objectSchema(
|
|
||||||
properties = buildJsonObject {
|
|
||||||
putJsonObject("id") {
|
|
||||||
put("type", "string")
|
|
||||||
put("description", "memory note id (mem-...)")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required = listOf("id"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) { args ->
|
) { args ->
|
||||||
val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}"""
|
if (store.delete(args.id)) """{"ok":true,"deleted":"${args.id}"}""" else """{"ok":false,"missing":"${args.id}"}"""
|
||||||
if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}"""
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-21
@@ -1,34 +1,23 @@
|
|||||||
package pw.binom.agentik.standalone.agent.memory
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete)
|
* Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete)
|
||||||
* поверх конкретного [MemoryStore]. Каждый инструмент возвращает JSON-строку,
|
* поверх конкретного [MemoryStore]. Каждый инструмент возвращает JSON-строку,
|
||||||
* совместимую с тем, что отдают остальные NamedTool'ы в проекте.
|
* совместимую с тем, что отдают остальные LiteTool'ы в проекте.
|
||||||
|
*
|
||||||
|
* Реализация каждого тула — типизированный `@Serializable Args` + suspend handler —
|
||||||
|
* через `pw.binom.agentik.toolsets.liteToolSuspend` (сам построен на
|
||||||
|
* `pw.binom.litert.tools.kotlinx.serialization.liteTool`).
|
||||||
*/
|
*/
|
||||||
object MemoryToolsFactory {
|
object MemoryToolsFactory {
|
||||||
|
|
||||||
fun create(store: MemoryStore): List<NamedTool> = listOf(
|
fun create(store: MemoryStore): List<LiteTool> = listOf(
|
||||||
NamedTool("memory_save", saveTool(store)),
|
saveTool(store),
|
||||||
NamedTool("memory_read", readTool(store)),
|
readTool(store),
|
||||||
NamedTool("memory_list", listTool(store)),
|
listTool(store),
|
||||||
NamedTool("memory_delete", deleteTool(store)),
|
deleteTool(store),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Адаптер из suspend-tool в синхронный [LiteTool]. LiteTool-контракт на
|
|
||||||
* JVM-движке — синхронный; [runBlocking] выполняет suspend-лямбду в том же
|
|
||||||
* потоке, что и сам LiteLlm-вызов (LiteTool.invoke синхронен).
|
|
||||||
*/
|
|
||||||
internal class SyncLiteTool(
|
|
||||||
private val describeJson: String,
|
|
||||||
private val handler: suspend (String) -> String,
|
|
||||||
) : LiteTool {
|
|
||||||
override fun describe(): String = describeJson
|
|
||||||
override fun invoke(arguments: String): String = runBlocking { handler(arguments) }
|
|
||||||
}
|
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.soul
|
||||||
|
|
||||||
|
import pw.binom.agentik.agent.Component
|
||||||
|
import pw.binom.agentik.agent.MutableAgent
|
||||||
|
import pw.binom.agentik.agent.SystemPromptProvider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключает "душу" — фиксированную секцию системного промпта,
|
||||||
|
* идущую первой (до базы, навыков, памяти).
|
||||||
|
*
|
||||||
|
* Используется для статичной персональной информации (persona, поведение),
|
||||||
|
* которую LLM должна видеть в каждом turn'е.
|
||||||
|
*/
|
||||||
|
class SoulComponent(
|
||||||
|
private val body: String?,
|
||||||
|
) : Component {
|
||||||
|
|
||||||
|
private var provider: SoulSystemProvider? = null
|
||||||
|
|
||||||
|
override fun install(agent: MutableAgent) {
|
||||||
|
if (body.isNullOrBlank()) return
|
||||||
|
val sp = SoulSystemProvider(body)
|
||||||
|
agent.systemProviders.add(sp)
|
||||||
|
provider = sp
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uninstall(agent: MutableAgent) {
|
||||||
|
provider?.let { agent.systemProviders.remove(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SoulSystemProvider(
|
||||||
|
private val body: String,
|
||||||
|
) : SystemPromptProvider {
|
||||||
|
override fun getSection(conversationId: String): String = body.trim()
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.llm
|
|
||||||
|
|
||||||
import pw.binom.litert.LiteConfig
|
|
||||||
import pw.binom.litert.LiteLlm
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory для движка Google LiteRT-LM на JVM.
|
|
||||||
*
|
|
||||||
* `litert-google` опубликован только как Android AAR (с .so внутри), поэтому на JVM
|
|
||||||
* приходится вручную подгружать JNI-библиотеки LiteRT-LM, прежде чем инстанциировать
|
|
||||||
* движок. Эта функция:
|
|
||||||
*
|
|
||||||
* 1. Резолвит `pw.binom.litert.google.GoogleLiteLlm` через reflection.
|
|
||||||
* 2. Если процесс уже загрузил нативные библиотеки (`-Djava.library.path`) — успешно
|
|
||||||
* создаёт движок.
|
|
||||||
* 3. Если нет — кидает `IllegalStateException` с инструкцией по настройке.
|
|
||||||
*/
|
|
||||||
fun googleLiteLlmJvm(config: LiteConfig): LiteLlm {
|
|
||||||
val cls = try {
|
|
||||||
Class.forName("pw.binom.litert.google.GoogleLiteLlm")
|
|
||||||
} catch (e: ClassNotFoundException) {
|
|
||||||
error(
|
|
||||||
"litert-google classes are not on the classpath. " +
|
|
||||||
"Add 'pw.binom.litert:litert-google-android:6' as a runtime dependency " +
|
|
||||||
"to use the GOOGLE backend on JVM."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val ctor = cls.constructors.firstOrNull { it.parameterCount == 1 }
|
|
||||||
?: error("pw.binom.litert.google.GoogleLiteLlm constructor not found")
|
|
||||||
val engine = try {
|
|
||||||
ctor.newInstance(config)
|
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
|
||||||
throw IllegalStateException(
|
|
||||||
"LiteRT-LM native libraries are not loaded. " +
|
|
||||||
"Extract .so/.dylib/.dll from litertlm-android-0.16.1.aar and pass " +
|
|
||||||
"-Djava.library.path=<dir>, or build :standalone for the androidJvm target.",
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
} catch (e: java.lang.reflect.InvocationTargetException) {
|
|
||||||
throw e.targetException ?: e
|
|
||||||
}
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return engine as LiteLlm
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun error(message: String): Nothing = throw IllegalStateException(message)
|
|
||||||
@@ -5,6 +5,7 @@ import pw.binom.litert.LiteBackend
|
|||||||
import pw.binom.litert.LiteConfig
|
import pw.binom.litert.LiteConfig
|
||||||
import pw.binom.litert.LiteExperimental
|
import pw.binom.litert.LiteExperimental
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.google.googleLiteLlm
|
||||||
import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig
|
import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig
|
||||||
import pw.binom.litert.openai.openAiLiteLlm
|
import pw.binom.litert.openai.openAiLiteLlm
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ data class LlmConfig(
|
|||||||
|
|
||||||
fun createLlm(): LiteLlm = when (backend) {
|
fun createLlm(): LiteLlm = when (backend) {
|
||||||
LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai).toLitertConfig())
|
LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai).toLitertConfig())
|
||||||
LlmBackend.GOOGLE -> googleLiteLlmJvm(google!!.toLiteConfig())
|
LlmBackend.GOOGLE -> googleLiteLlm(google!!.toLiteConfig())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun modelInfo(): String = when (backend) {
|
fun modelInfo(): String = when (backend) {
|
||||||
|
|||||||
+59
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-12
@@ -4,8 +4,8 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
|
||||||
import pw.binom.agentik.reflection.Reflection
|
import pw.binom.agentik.reflection.Reflection
|
||||||
|
import pw.binom.agentik.memory.MemorySystemGuidance
|
||||||
|
|
||||||
class ChatAgentReflectionTest {
|
class ChatAgentReflectionTest {
|
||||||
|
|
||||||
@@ -13,9 +13,7 @@ class ChatAgentReflectionTest {
|
|||||||
fun `buildSystemPrompt omits section when reflections empty`() {
|
fun `buildSystemPrompt omits section when reflections empty`() {
|
||||||
val prompt = buildSystemPrompt(
|
val prompt = buildSystemPrompt(
|
||||||
base = "base",
|
base = "base",
|
||||||
skills = SkillCatalog.EMPTY,
|
|
||||||
memoryEnabled = false,
|
memoryEnabled = false,
|
||||||
soulBody = null,
|
|
||||||
reflections = emptyList(),
|
reflections = emptyList(),
|
||||||
)
|
)
|
||||||
assertFalse(prompt.contains("Self-reflection"))
|
assertFalse(prompt.contains("Self-reflection"))
|
||||||
@@ -35,9 +33,7 @@ class ChatAgentReflectionTest {
|
|||||||
)
|
)
|
||||||
val prompt = buildSystemPrompt(
|
val prompt = buildSystemPrompt(
|
||||||
base = "base",
|
base = "base",
|
||||||
skills = SkillCatalog.EMPTY,
|
|
||||||
memoryEnabled = false,
|
memoryEnabled = false,
|
||||||
soulBody = null,
|
|
||||||
reflections = listOf(r),
|
reflections = listOf(r),
|
||||||
)
|
)
|
||||||
assertTrue(prompt.contains("Self-reflection"))
|
assertTrue(prompt.contains("Self-reflection"))
|
||||||
@@ -48,7 +44,10 @@ class ChatAgentReflectionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `buildSystemPrompt places reflection section after memory and before soul prepend`() {
|
fun `buildSystemPrompt places reflection section after memory section`() {
|
||||||
|
// Memory теперь приходит от MemoryComponent → SystemPromptProvider; порядок
|
||||||
|
// провайдеров определяется порядком install(). Тест проверяет базовый
|
||||||
|
// порядок внутри buildSystemPrompt — reflection идёт после памяти.
|
||||||
val r = Reflection(
|
val r = Reflection(
|
||||||
id = "r1",
|
id = "r1",
|
||||||
conversationId = null,
|
conversationId = null,
|
||||||
@@ -60,15 +59,15 @@ class ChatAgentReflectionTest {
|
|||||||
)
|
)
|
||||||
val prompt = buildSystemPrompt(
|
val prompt = buildSystemPrompt(
|
||||||
base = "base",
|
base = "base",
|
||||||
skills = SkillCatalog.EMPTY,
|
|
||||||
memoryEnabled = true,
|
memoryEnabled = true,
|
||||||
soulBody = "I am a soul",
|
|
||||||
reflections = listOf(r),
|
reflections = listOf(r),
|
||||||
)
|
)
|
||||||
// soul первый, reflection последняя
|
assertTrue(prompt.contains("base"), "base missing: $prompt")
|
||||||
val soulIdx = prompt.indexOf("I am a soul")
|
assertTrue(prompt.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(20)), "memory section missing: $prompt")
|
||||||
|
assertTrue(prompt.contains("Self-reflection"), "reflection section missing: $prompt")
|
||||||
|
val baseIdx = prompt.indexOf("base")
|
||||||
|
val memIdx = prompt.indexOf(MemorySystemGuidance.MEMORY_GUIDANCE.take(20))
|
||||||
val reflIdx = prompt.indexOf("Self-reflection")
|
val reflIdx = prompt.indexOf("Self-reflection")
|
||||||
assertTrue(soulIdx >= 0 && reflIdx >= 0)
|
assertTrue(baseIdx < memIdx && memIdx < reflIdx, "expected base < memory < reflection, got $baseIdx/$memIdx/$reflIdx")
|
||||||
assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-26
@@ -10,13 +10,14 @@ import kotlinx.coroutines.test.runTest
|
|||||||
import pw.binom.agentik.outbox.AgentEvent
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||||
|
import pw.binom.agentik.skill.mining.SkillReadTool
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
import pw.binom.agentik.skills.SkillFile
|
import pw.binom.agentik.skills.SkillFile
|
||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import pw.binom.litert.LiteContentPart
|
import pw.binom.litert.LiteContentPart
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
import pw.binom.litert.LiteConversationConfig
|
import pw.binom.litert.LiteConversationConfig
|
||||||
@@ -37,16 +38,15 @@ import kotlin.test.assertNotNull
|
|||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
class ChatAgentTest {
|
class ChatAgentTest {
|
||||||
|
|
||||||
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
private lateinit var fakeLlm: FakeLiteLlm
|
private lateinit var fakeLlm: FakeLiteLlm
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
|
sqliteStores = SqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
|
||||||
fakeLlm = FakeLiteLlm()
|
fakeLlm = FakeLiteLlm()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,25 +56,28 @@ class ChatAgentTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun newAgent(
|
private fun newAgent(
|
||||||
sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores,
|
sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores = this.sqliteStores,
|
||||||
llm: LiteLlm = this.fakeLlm,
|
llm: LiteLlm = this.fakeLlm,
|
||||||
tools: List<NamedTool> = emptyList(),
|
tools: List<LiteTool> = emptyList(),
|
||||||
skills: SkillCatalog = SkillCatalog.EMPTY,
|
skillStore: pw.binom.agentik.skills.SkillStore? = null,
|
||||||
): ChatAgent = ChatAgent(
|
): ChatAgent {
|
||||||
|
val agent = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
mutableConversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
messageStore = sqliteStores.messages,
|
messageStore = sqliteStores.messages,
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
reflectionStore = sqliteStores.reflections,
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
skillStore = skillStore,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
llmConfig = LlmConfig(
|
llmConfig = LlmConfig(
|
||||||
backend = LlmBackend.OPENAI,
|
backend = LlmBackend.OPENAI,
|
||||||
systemPrompt = "be brief",
|
systemPrompt = "be brief",
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
),
|
),
|
||||||
tools = tools,
|
|
||||||
skills = skills,
|
|
||||||
)
|
)
|
||||||
|
tools.forEach { agent.addTestTool(it) }
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
|
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
|
||||||
@@ -93,14 +96,13 @@ class ChatAgentTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `skills are appended to the system prompt passed to LLM`() = runTest {
|
fun `skills are appended to the system prompt passed to LLM`() = runTest {
|
||||||
// Skills добавляются в system prompt на лету при сборке ChatAgent.
|
// Скиллы теперь приходят через SkillComponent → SkillSystemProvider →
|
||||||
// Проверяем это через то, что увидит LLM — systemInstruction в
|
// buildRuntimeSystemPrompt() при каждом turn'е. Проверяем через
|
||||||
// LiteConversationConfig (а не через working_memory, куда теперь
|
// systemInstruction, который увидит LLM.
|
||||||
// ничего про system prompt не пишется).
|
val skillStore = InMemorySkillStore(
|
||||||
val skills = SkillCatalog(
|
|
||||||
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
||||||
)
|
)
|
||||||
val agent = newAgent(skills = skills)
|
val agent = newAgent(skillStore = skillStore)
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
fakeLlm.reply = "ok"
|
fakeLlm.reply = "ok"
|
||||||
conv.send(listOf(Content.Text("hi")))
|
conv.send(listOf(Content.Text("hi")))
|
||||||
@@ -116,10 +118,10 @@ class ChatAgentTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `read_skill tool is registered when skills present`() = runTest {
|
fun `read_skill tool is registered when skills present`() = runTest {
|
||||||
val skills = SkillCatalog(
|
val skillStore = InMemorySkillStore(
|
||||||
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
||||||
)
|
)
|
||||||
val agent = newAgent(skills = skills)
|
val agent = newAgent(skillStore = skillStore)
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
fakeLlm.reply = "ok"
|
fakeLlm.reply = "ok"
|
||||||
conv.send(listOf(Content.Text("hi")))
|
conv.send(listOf(Content.Text("hi")))
|
||||||
@@ -419,14 +421,15 @@ class ChatAgentTest {
|
|||||||
|
|
||||||
// LLM скриптован: tool call.
|
// LLM скриптован: tool call.
|
||||||
fakeLlm.scriptedReplies = mutableListOf(
|
fakeLlm.scriptedReplies = mutableListOf(
|
||||||
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))),
|
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to """{"q":"hi"}""")),
|
||||||
)
|
)
|
||||||
|
|
||||||
val echoTool = object : LiteTool {
|
val echoTool = object : LiteTool {
|
||||||
|
override val name: String = "echo_tool"
|
||||||
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
|
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
|
||||||
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
|
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
|
||||||
}
|
}
|
||||||
agent.registerToolForTest("echo_tool", echoTool)
|
agent.addTestTool(echoTool)
|
||||||
|
|
||||||
// Подписываемся ДО send — SharedFlow без replay
|
// Подписываемся ДО send — SharedFlow без replay
|
||||||
val events = mutableListOf<ProtoEvent>()
|
val events = mutableListOf<ProtoEvent>()
|
||||||
@@ -472,7 +475,7 @@ class ChatAgentTest {
|
|||||||
// Поднимаем file-backed БД, создаём temp-беседу
|
// Поднимаем file-backed БД, создаём temp-беседу
|
||||||
sqliteStores.close()
|
sqliteStores.close()
|
||||||
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
||||||
sqliteStores = KsqliteStores.open(dbPath)
|
sqliteStores = SqliteStores.open(dbPath)
|
||||||
val agent1 = ChatAgent(
|
val agent1 = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
mutableConversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
@@ -492,7 +495,7 @@ class ChatAgentTest {
|
|||||||
|
|
||||||
// Переоткрываем БД — temp-беседа не должна пережить рестарт
|
// Переоткрываем БД — temp-беседа не должна пережить рестарт
|
||||||
sqliteStores.close()
|
sqliteStores.close()
|
||||||
sqliteStores = KsqliteStores.open(dbPath)
|
sqliteStores = SqliteStores.open(dbPath)
|
||||||
val agent2 = ChatAgent(
|
val agent2 = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
mutableConversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
@@ -514,7 +517,7 @@ class ChatAgentTest {
|
|||||||
fun `non-temp conversation persists across agent instances`() = runTest {
|
fun `non-temp conversation persists across agent instances`() = runTest {
|
||||||
sqliteStores.close()
|
sqliteStores.close()
|
||||||
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
||||||
sqliteStores = KsqliteStores.open(dbPath)
|
sqliteStores = SqliteStores.open(dbPath)
|
||||||
val agent1 = ChatAgent(
|
val agent1 = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
mutableConversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
@@ -532,7 +535,7 @@ class ChatAgentTest {
|
|||||||
val id = conv.id
|
val id = conv.id
|
||||||
|
|
||||||
sqliteStores.close()
|
sqliteStores.close()
|
||||||
sqliteStores = KsqliteStores.open(dbPath)
|
sqliteStores = SqliteStores.open(dbPath)
|
||||||
val agent2 = ChatAgent(
|
val agent2 = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
mutableConversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
@@ -564,11 +567,12 @@ class ChatAgentTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `tool-call loop executes registered tool and feeds result back`() = runTest {
|
fun `tool-call loop executes registered tool and feeds result back`() = runTest {
|
||||||
val echoTool = object : LiteTool {
|
val echoTool = object : LiteTool {
|
||||||
|
override val name: String = "echo"
|
||||||
override fun describe(): String = """{"type":"function","function":{"name":"echo"}}"""
|
override fun describe(): String = """{"type":"function","function":{"name":"echo"}}"""
|
||||||
override fun invoke(arguments: String): String = "echoed: $arguments"
|
override fun invoke(arguments: String): String = "echoed: $arguments"
|
||||||
}
|
}
|
||||||
val toolLlm = ToolLoopFakeLiteLlm()
|
val toolLlm = ToolLoopFakeLiteLlm()
|
||||||
val agent = newAgent(llm = toolLlm, tools = listOf(NamedTool("echo", echoTool)))
|
val agent = newAgent(llm = toolLlm, tools = listOf(echoTool))
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
||||||
@@ -624,6 +628,12 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
|
|||||||
return object : LiteConversation {
|
return object : LiteConversation {
|
||||||
private val hist = mutableListOf<LiteMessage>()
|
private val hist = mutableListOf<LiteMessage>()
|
||||||
override val history: List<LiteMessage> get() = hist.toList()
|
override val history: List<LiteMessage> get() = hist.toList()
|
||||||
|
override var systemInstruction: String? = config.systemInstruction
|
||||||
|
override var tools: List<pw.binom.litert.LiteTool> = config.tools
|
||||||
|
override fun replaceHistory(newHistory: List<LiteMessage>) {
|
||||||
|
hist.clear()
|
||||||
|
hist.addAll(newHistory)
|
||||||
|
}
|
||||||
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||||
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||||
hist.add(LiteMessage(LiteRole.USER, contents))
|
hist.add(LiteMessage(LiteRole.USER, contents))
|
||||||
@@ -632,7 +642,7 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
|
|||||||
LiteDelta(
|
LiteDelta(
|
||||||
text = "",
|
text = "",
|
||||||
isDone = true,
|
isDone = true,
|
||||||
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))),
|
toolCalls = listOf(LiteToolCall(name = "echo", arguments = """{"x":"hi"}""")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-6
@@ -2,7 +2,7 @@ package pw.binom.agentik.standalone.agent
|
|||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import pw.binom.agentik.toolsets.ToolsetContribution
|
import pw.binom.agentik.toolsets.ToolsetContribution
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
@@ -20,6 +20,7 @@ import kotlin.test.assertTrue
|
|||||||
class ChatAgentToolsetsTest {
|
class ChatAgentToolsetsTest {
|
||||||
|
|
||||||
private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = response
|
override fun invoke(arguments: String) = response
|
||||||
}
|
}
|
||||||
@@ -39,8 +40,8 @@ class ChatAgentToolsetsTest {
|
|||||||
|
|
||||||
private fun newAgent(
|
private fun newAgent(
|
||||||
toolsets: List<ToolsetContribution> = emptyList(),
|
toolsets: List<ToolsetContribution> = emptyList(),
|
||||||
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> {
|
): Pair<ChatAgent, pw.binom.agentik.standalone.persistence.SqliteStores> {
|
||||||
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
|
val sqliteStores = SqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
|
||||||
val agent = ChatAgent(
|
val agent = ChatAgent(
|
||||||
id = "test-agent",
|
id = "test-agent",
|
||||||
mutableConversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
@@ -183,9 +184,18 @@ class ChatAgentToolsetsTest {
|
|||||||
|
|
||||||
// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов.
|
// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов.
|
||||||
internal fun ChatConversation.systemPromptForTest(): String {
|
internal fun ChatConversation.systemPromptForTest(): String {
|
||||||
// Через рефлексию достаём private val systemPrompt.
|
// v12+: секция "## Toolsets" приходит от ToolsetSystemProvider'а в
|
||||||
val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true }
|
// buildRuntimeSystemPrompt() (динамически). Получаем промт агента
|
||||||
return f.get(this) as String
|
// через тот же путь, что и production turn'ы.
|
||||||
|
val agentField = ChatAgent::class.java.declaredFields.first { it.name == "components" }.apply { isAccessible = true }
|
||||||
|
val agent = agentField.let {
|
||||||
|
// Доступ через компоненты неудобен — лучше через саму agent-instance.
|
||||||
|
// ChatConversation хранит systemPromptResolver, который замыкается над ChatAgent.
|
||||||
|
val resolverField = this::class.java.declaredFields.first { it.name == "systemPromptResolver" }.apply { isAccessible = true }
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(resolverField.get(this) as () -> String).invoke()
|
||||||
|
}
|
||||||
|
return agent
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String {
|
internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String {
|
||||||
|
|||||||
+3
-3
@@ -11,7 +11,7 @@ import pw.binom.agentik.memory.md.KeywordMdReviewer
|
|||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import pw.binom.agentik.proto.Content as ProtoContent
|
import pw.binom.agentik.proto.Content as ProtoContent
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
import pw.binom.litert.LiteConversationConfig
|
import pw.binom.litert.LiteConversationConfig
|
||||||
@@ -40,12 +40,12 @@ import pw.binom.agentik.llm.tools.SummaryTurn
|
|||||||
*/
|
*/
|
||||||
class CompactionTest {
|
class CompactionTest {
|
||||||
|
|
||||||
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
private lateinit var fakeLlm: FakeLiteLlm
|
private lateinit var fakeLlm: FakeLiteLlm
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
|
sqliteStores = SqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
|
||||||
fakeLlm = FakeLiteLlm()
|
fakeLlm = FakeLiteLlm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import pw.binom.litert.LiteToolCall
|
|||||||
internal class FakeLiteLlm : LiteLlm {
|
internal class FakeLiteLlm : LiteLlm {
|
||||||
sealed class Reply {
|
sealed class Reply {
|
||||||
data class Text(val text: String) : Reply()
|
data class Text(val text: String) : Reply()
|
||||||
data class ToolCalls(val calls: List<Pair<String, Map<String, Any?>>>) : Reply()
|
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val backendName: String = "fake"
|
override val backendName: String = "fake"
|
||||||
@@ -67,6 +67,8 @@ internal class FakeLiteConversation(
|
|||||||
val initialMessages: List<LiteMessage> = config.initialMessages
|
val initialMessages: List<LiteMessage> = config.initialMessages
|
||||||
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
||||||
override val history: List<LiteMessage> get() = mutableHistory.toList()
|
override val history: List<LiteMessage> get() = mutableHistory.toList()
|
||||||
|
override var systemInstruction: String? = config.systemInstruction
|
||||||
|
override var tools: List<pw.binom.litert.LiteTool> = config.tools
|
||||||
|
|
||||||
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
||||||
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||||
@@ -104,6 +106,11 @@ internal class FakeLiteConversation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
override fun replaceHistory(newHistory: List<LiteMessage>) {
|
||||||
|
mutableHistory.clear()
|
||||||
|
mutableHistory.addAll(newHistory)
|
||||||
|
}
|
||||||
|
|
||||||
override fun send(prompt: String): String {
|
override fun send(prompt: String): String {
|
||||||
parent.lastContents = listOf(LiteContentPart.Text(prompt))
|
parent.lastContents = listOf(LiteContentPart.Text(prompt))
|
||||||
return parent.reply
|
return parent.reply
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
|
import pw.binom.agentik.skills.SkillFile
|
||||||
|
import pw.binom.agentik.skills.SkillStore
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
|
/** Минимальный in-memory [SkillStore] для commonTest. */
|
||||||
|
class InMemorySkillStore(initial: List<SkillFile> = emptyList()) : SkillStore {
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
private val active = LinkedHashMap<String, SkillFile>().apply {
|
||||||
|
initial.forEach { put(it.name, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override val catalog: SkillCatalog
|
||||||
|
get() = lock.withLock { SkillCatalog(active.values.toList()) }
|
||||||
|
|
||||||
|
override fun upsert(skill: SkillFile) = lock.withLock {
|
||||||
|
active[skill.name] = skill
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun remove(name: String): Boolean = lock.withLock {
|
||||||
|
active.remove(name) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
+8
-8
@@ -30,7 +30,7 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
|||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||||
import pw.binom.agentik.llm.tools.SummaryTurn
|
import pw.binom.agentik.llm.tools.SummaryTurn
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
@@ -49,13 +49,13 @@ import kotlin.time.Instant
|
|||||||
*/
|
*/
|
||||||
class MemoryWiringTest {
|
class MemoryWiringTest {
|
||||||
|
|
||||||
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
private lateinit var fakeLlm: FakeLiteLlm
|
private lateinit var fakeLlm: FakeLiteLlm
|
||||||
private lateinit var root: Path
|
private lateinit var root: Path
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
|
sqliteStores = SqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
|
||||||
fakeLlm = FakeLiteLlm()
|
fakeLlm = FakeLiteLlm()
|
||||||
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
|
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
|
||||||
SystemFileSystem.createDirectories(root, mustCreate = true)
|
SystemFileSystem.createDirectories(root, mustCreate = true)
|
||||||
@@ -173,7 +173,7 @@ class MemoryWiringTest {
|
|||||||
assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names)
|
assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names)
|
||||||
// Каждый tool описывается валидной JSON-схемой:
|
// Каждый tool описывается валидной JSON-схемой:
|
||||||
for (t in tools) {
|
for (t in tools) {
|
||||||
assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}")
|
assertTrue(t.describe().contains("\"description\""), "describe() for ${t.name}")
|
||||||
}
|
}
|
||||||
system.close()
|
system.close()
|
||||||
}
|
}
|
||||||
@@ -182,23 +182,23 @@ class MemoryWiringTest {
|
|||||||
fun `memory_save tool round-trips a note through the store`() = runBlocking {
|
fun `memory_save tool round-trips a note through the store`() = runBlocking {
|
||||||
val system = openMdMemorySystem(root)
|
val system = openMdMemorySystem(root)
|
||||||
val tools = MemoryToolsFactory.create(system.store).associateBy { it.name }
|
val tools = MemoryToolsFactory.create(system.store).associateBy { it.name }
|
||||||
val saveResult = tools.getValue("memory_save").tool.invoke(
|
val saveResult = tools.getValue("memory_save").invoke(
|
||||||
"""{"category":"preference","content":"prefers tabs over spaces"}""",
|
"""{"category":"preference","content":"prefers tabs over spaces"}""",
|
||||||
)
|
)
|
||||||
assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult")
|
assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult")
|
||||||
assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult")
|
assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult")
|
||||||
|
|
||||||
val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""")
|
val listResult = tools.getValue("memory_list").invoke("""{"limit":10}""")
|
||||||
assertTrue(listResult.contains("prefers tabs over spaces"),
|
assertTrue(listResult.contains("prefers tabs over spaces"),
|
||||||
"list returned: $listResult")
|
"list returned: $listResult")
|
||||||
|
|
||||||
val readResult = tools.getValue("memory_read").tool.invoke(
|
val readResult = tools.getValue("memory_read").invoke(
|
||||||
"""{"query":"tabs","top_k":3}""",
|
"""{"query":"tabs","top_k":3}""",
|
||||||
)
|
)
|
||||||
assertTrue(readResult.contains("prefers tabs over spaces"),
|
assertTrue(readResult.contains("prefers tabs over spaces"),
|
||||||
"read returned: $readResult")
|
"read returned: $readResult")
|
||||||
|
|
||||||
val deleteResult = tools.getValue("memory_delete").tool.invoke(
|
val deleteResult = tools.getValue("memory_delete").invoke(
|
||||||
Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m ->
|
Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m ->
|
||||||
"""{"id":"${m.groupValues[1]}"}"""
|
"""{"id":"${m.groupValues[1]}"}"""
|
||||||
} ?: error("save did not return id"),
|
} ?: error("save did not return id"),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import java.io.File
|
|||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.agentik.skills.DiskSkillStore
|
import pw.binom.agentik.skills.DiskSkillStore
|
||||||
|
import pw.binom.agentik.skill.mining.SkillSaveTool
|
||||||
|
import pw.binom.agentik.skill.mining.SkillDeleteTool
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
import pw.binom.agentik.skills.SkillFile
|
import pw.binom.agentik.skills.SkillFile
|
||||||
import pw.binom.agentik.skills.SkillStore
|
import pw.binom.agentik.skills.SkillStore
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertSame
|
import kotlin.test.assertSame
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
import pw.binom.agentik.mcp.bridge.McpConfig
|
import pw.binom.agentik.mcp.bridge.McpConfig
|
||||||
import pw.binom.agentik.mcp.bridge.McpRegistry
|
import pw.binom.agentik.mcp.bridge.McpRegistry
|
||||||
|
|
||||||
@@ -16,28 +15,26 @@ class McpRegistryTest {
|
|||||||
val registry = McpRegistry.fromConfig(McpConfig.empty())
|
val registry = McpRegistry.fromConfig(McpConfig.empty())
|
||||||
assertEquals(0, registry.allTools.size)
|
assertEquals(0, registry.allTools.size)
|
||||||
assertEquals(0, registry.connectedServerCount)
|
assertEquals(0, registry.connectedServerCount)
|
||||||
assertEquals(emptyList(), registry.namedTools)
|
|
||||||
registry.close()
|
registry.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `empty servers list produces empty registry`() {
|
fun `empty servers list produces empty registry`() {
|
||||||
val registry = McpRegistry(servers = emptyList())
|
val registry = McpRegistry(servers = emptyList())
|
||||||
assertEquals(0, registry.namedTools.size)
|
|
||||||
assertEquals(0, registry.allTools.size)
|
assertEquals(0, registry.allTools.size)
|
||||||
registry.close()
|
registry.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `NamedTool holds name and tool reference`() {
|
fun `LiteTool name is exposed`() {
|
||||||
val noop: LiteTool = object : LiteTool {
|
val noop: LiteTool = object : LiteTool {
|
||||||
|
override val name: String = "server__echo"
|
||||||
override fun describe(): String = "{}"
|
override fun describe(): String = "{}"
|
||||||
override fun invoke(arguments: String): String = ""
|
override fun invoke(arguments: String): String = ""
|
||||||
}
|
}
|
||||||
val nt = NamedTool(name = "server__echo", tool = noop)
|
assertEquals("server__echo", noop.name)
|
||||||
assertEquals("server__echo", nt.name)
|
assertSame(noop, noop)
|
||||||
assertSame(noop, nt.tool)
|
assertEquals("{}", noop.describe())
|
||||||
assertEquals("{}", nt.tool.describe())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+8
-3
@@ -8,7 +8,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.BeforeTest
|
import kotlin.test.BeforeTest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
@@ -22,11 +22,11 @@ import kotlin.time.Instant
|
|||||||
|
|
||||||
class PersistenceTest {
|
class PersistenceTest {
|
||||||
|
|
||||||
private lateinit var stores: KsqliteStores
|
private lateinit var stores: SqliteStores
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
|
stores = SqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterTest
|
@AfterTest
|
||||||
@@ -116,6 +116,11 @@ class PersistenceTest {
|
|||||||
val removed = stores.conversations.delete("c1")
|
val removed = stores.conversations.delete("c1")
|
||||||
assertTrue(removed)
|
assertTrue(removed)
|
||||||
assertNull(stores.conversations.get("c1"))
|
assertNull(stores.conversations.get("c1"))
|
||||||
|
// conversations.delete больше не каскадит — это делает владелец
|
||||||
|
// lifecycle (ChatAgent). Здесь проверяем, что каждый store делает
|
||||||
|
// только свою таблицу чистой при ручном вызове.
|
||||||
|
stores.messages.clear("c1")
|
||||||
|
stores.workingMemory.clear("c1")
|
||||||
assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList())
|
assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList())
|
||||||
assertEquals(emptyList(), stores.workingMemory.list("c1"))
|
assertEquals(emptyList(), stores.workingMemory.list("c1"))
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user