12 Commits

Author SHA1 Message Date
subochev 059c23e3ab Изменение выносит типы контента в :content-api и разделяет потоки событий. 2026-09-30 12:41:10 +03:00
subochev bd7e079780 Изменения вводят live-канал OnlineOutbox и фоновую синхронизацию диалогов. 2026-09-30 11:43:49 +03:00
subochev 509763cf36 proto/server/client: Agent.info, Working-маркер хода, /record, таймауты запросов
- proto: Agent.info (AgentInfo: name/description/usefulness) — человекочитаемое
  имя отдельно от opaque id; сериализация и тесты
- server: GET {path} отдаёт Agent.info; GET /conversations/{id}/record —
  ConversationRecord без handle'а (для клиентского кэша после AgentEvent.Created)
- outbox: Event.Working — первый event хода, эмитится из Conversation.send()
  до LLM-цикла и turnLock, чтобы UI показал спиннер сразу
- standalone: AGENTIK_NAME/AGENTIK_DESCRIPTION/AGENTIK_USEFULNESS → AgentInfo
- client: AgentClient.create eagerly фетчит info (GET {baseUrl})
- client: HttpConversationStore.get читает /record (ConversationRecord,
  а не ConversationSnapshot — рассинхрон типов)
- client: noReadTimeout() на POST /conversations/{id}/messages — сервер отвечает
  по завершении всего хода агента (реально 0.5–144 с), дефолтные 15 с рвали
  живую реплику на клиенте
- journal-api: ConversationRecord @Serializable
- ksqlite 0.1.3 → 0.1.4
- .gitignore: runtime-данные standalone-агента и hs_err-дампы
2026-09-28 10:09:34 +03:00
subochev 4156e5f95f chore(deps): refactor ksqlite dependency to use version catalog 2026-09-24 16:12:28 +03:00
subochev f191387e99 chore(deps): bump ksqlite 0.1.2 → 0.1.3 + journal count routes
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m18s
ksqlite 0.1.3 опубликован в Maven Central — POM/module-metadata/jar все
на месте. Поднят в шести модулях (vector-index-ksqlite, journal-ksqlite,
context-ksqlite, reflection-ksqlite, standalone, memory-md-vector);
комментарии про 0.1.2 в build.gradle.kts обновлены.

Расширение :journal-api: добавлены два count-метода (total + count after
cursor) в интерфейс JournalStore — реализованы в :journal-ksqlite /
:journal-inmemory. Над ними добавлены HTTP-endpoint'ы
  GET /journal/conversations/{id}/count
  GET /journal/conversations/{id}/count?after=
(объединены в один маршрут с опциональным параметром) и HTTP-клиент
HttpJournalStore.count/concount. Сервер-фасад расширен тестом
JournalRoutesCountTest (5 кейсов через embedded CIO + реальный
InMemoryJournalStore).

:server:jvmTest 10/0, :standalone:jvmTest 129/0, :journal-ksqlite:jvmTest 25/0,
:journal-inmemory:jvmTest 19/0. jvmTest агрегат 425/0/0.
2026-09-23 15:59:25 +03:00
subochev d1b4f897b7 Add GET /conversations/{id}/count endpoint for total/filtered message counts, update JournalStore API, and implement client/server support with tests.
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m4s
2026-09-23 05:43:55 +03:00
subochev a81d92f489 Add count methods to JournalStore API and implementations for message counting per conversation (count(conversationId) and count(conversationId, after)), with supporting tests.
release / Publish KMP libraries → caffeine Nexus (release) Failing after 32s
2026-09-23 05:31:13 +03:00
subochev 3e583ac8ea Relocate CI workflow file from .gitea/workflows/ci.yml to .gitea/ci.yml and update README.md accordingly.
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m16s
2026-09-23 04:47:43 +03:00
subochev f878d1c79b Update deployment host IP in standalone/build.gradle.kts configuration
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:46:37 +03:00
subochev 266ec38c1b Refactor: replace BackgroundScheduler with ReflectionScheduler, migrate to outbox-driven event processing, and remove skill mining logic
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:40:45 +03:00
subochev e447525059 Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s
2026-09-23 03:48:38 +03:00
subochev 84f5fd84f3 remove :storage-ksqlite (conversation/message) and related tests; decouple schema from journal
ci / JVM build + tests (push) Successful in 6m6s
2026-09-22 16:01:05 +03:00
201 changed files with 6419 additions and 3078 deletions
+5
View File
@@ -30,3 +30,8 @@ agentik.db
agentik.db-shm agentik.db-shm
agentik.db-wal agentik.db-wal
memory-md/agentik-mem-*/ memory-md/agentik-mem-*/
# Runtime-данные standalone-агента (db/memory/skills при локальном запуске)
/standalone/agentik/
hs_err_pid*.log
core.*
+7 -6
View File
@@ -18,8 +18,9 @@ agentik/
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...) ├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск) ├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...) ├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
├── storage-inmemory/ in-memory реализация для тестов и Android │ (исторический, см. journal-api / context-api / reflection-api ниже)
├── storage-sqlite/ SQLite реализация для production ├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget ├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik ├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17 ├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
@@ -89,9 +90,9 @@ curl http://localhost:8080/health
- [`:memory-api`](memory-api/README.md) — контракт памяти. - [`:memory-api`](memory-api/README.md) — контракт памяти.
- [`:memory-md`](memory-md/README.md) — Hermes-style файл. - [`:memory-md`](memory-md/README.md) — Hermes-style файл.
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector. - [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
- [`:storage-core`](storage-core/README.md) — контракт storage. - [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация. - ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production. - ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер. - [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
## Где смотреть версии ## Где смотреть версии
@@ -124,7 +125,7 @@ SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппирован
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`): Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка - `.gitea/ci.yml` — PR-build, прогон тестов, проверка
shadowjar'ов. shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты - `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу. в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
-37
View File
@@ -1,37 +0,0 @@
Status of message-log-api migration:
DONE:
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
- Content.kt (sealed: Text, Image)
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
- MessageStore.kt (interface, TokenStats, MessageEvent)
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
6. Wrote /tmp/run_rename.sh that runs the python script.
PENDING:
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
- Verify build compiles (run standalone tests).
Files that need import updates (per search):
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
- standalone/src/jvmMain/.../ToolDispatcher.kt
- standalone/src/jvmMain/.../ConversationLoop.kt
- standalone/src/jvmTest/.../ChatAgentTest.kt
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
Workaround needed before continuing the migration.
+42
View File
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :proto — read-only Agent interface, который MutableAgent расширяет.
// Через api(), иначе downstream-impl ChatAgent не сможет
// override suspend-методы Agent.
api(project(":proto"))
// :memory-api — typealias ConversationTurn на memory-api одноимённый
// класс, иначе пер-конво компоненты (skill mining, reflection) не
// смогут передать его в SkillMiner.mine() напрямую.
api(project(":memory-api"))
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
api(libs.litert.api)
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
// listener) — оставлен как api, чтобы downstream не забывал объявить.
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.agent
/**
* Нашлёпка поверх [MutableAgent].
*
* Компонент сам регистрирует в агенте свои capability-провайдеры
* при [install] и снимает их при [uninstall]. Агент не знает заранее
* ни о структуре компонента, ни о его провайдерах — это просто
* хук для свободной композиции.
*
* Ktor-style API:
* ```
* val agent = ChatAgent(...)
* .install(SkillComponent(store, miner))
* .install(ReflectionComponent(reflectionStore, reflector))
* .install(MemoryComponent(memorySystem))
* ```
*
* Контракт:
* - [install] **синхронен**: компонент добавляет свои провайдеры в
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
* фоновые корутины — компонент запускает их через свой собственный
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
*/
interface Component {
/**
* Вызывается агентом при [MutableAgent.install].
*
* Типичные действия: добавить [SystemPromptProvider] в
* `agent.systemProviders`, добавить [ToolProvider] в
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
*/
fun install(agent: MutableAgent)
/**
* Вызывается агентом при [MutableAgent.uninstall] или при
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
* которые добавил в [install], и остановить фоновые джобы.
*/
fun uninstall(agent: MutableAgent)
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.agent
/**
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
*
* [MutableAgent] при создании/закрытии разговора вызывает
* [attachConversation] / [detachConversation] на каждом компоненте,
* реализующем этот интерфейс. Внутри компонент хранит
* [ConversationHandle] (или контекст вокруг него) и подписывается на
* нужные события.
*
* Компонент без [ConversationAware] остаётся чисто agent-level — он
* не получает per-conversation хуков.
*/
interface ConversationAware {
fun attachConversation(handle: ConversationHandle)
fun detachConversation(handle: ConversationHandle)
}
/**
* Минимальное окно в разговор, которое компонент видит через
* [ConversationAware]. Содержит только то, что нужно большинству
* per-conversation компонентов:
* - идентификатор (для подписки на события),
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
* - последние N turns (для LlmReflector / SkillMiner).
*
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
* чтобы компонент не лез в чужие обязанности.
*/
interface ConversationHandle : AutoCloseable {
val id: String
val isTemporal: Boolean
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
suspend fun recentTurns(limit: Int): List<ConversationTurn>
override fun close()
}
/**
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
* компоненты (skill mining, reflection) могли передавать его напрямую
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
* конвертации.
*/
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
@@ -0,0 +1,78 @@
package pw.binom.agentik.agent
import pw.binom.agentik.proto.Agent
/**
* Настраиваемая версия [Agent]: расширяет публичный contract агента
* install/uninstall-механикой компонентов ([Component]).
*
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
*
* Контракт:
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
*
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
* позже, в v1 держим KISS.
*/
interface MutableAgent : Agent {
/**
* Провайдеры секций system prompt, регистрируются компонентами через [install].
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
* system prompt конкретной беседы; возвращает `null`, если у него нет
* релевантной секции для данного контекста.
*
* Изменяется **только внутри `Component.install(this)` /
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
* напрямую в список не лезет.
*/
val systemProviders: MutableList<SystemPromptProvider>
/**
* Провайдеры tools, регистрируются компонентами через [install].
* [ToolProvider.tools] вызывается при формировании набора тулов
* для конкретной беседы; компонент решает сам, какие тулы отдавать
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
*/
val toolProviders: MutableList<ToolProvider>
/**
* Устанавливает [component] в агент: `component.install(this)` +
* агент запоминает компонент, чтобы при [close] корректно его снять.
*
* Возвращает `this` — для fluent-цепочек:
* ```
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
* ```
*/
fun install(component: Component): MutableAgent
/**
* Снимает [component]: `component.uninstall(this)` + забывает.
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
*/
fun uninstall(component: Component): MutableAgent
/**
* Оповещает все установленные компоненты, реализующие [ConversationAware],
* о появлении нового разговора. Компонент может подписаться на события,
* запустить фоновые задачи, проиндексировать turns и т.п.
*/
fun attachConversation(handle: ConversationHandle)
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
fun detachConversation(handle: ConversationHandle)
/**
* Освобождает ресурсы агента и снимает все установленные компоненты
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
* первым). Idempotent.
*/
override fun close()
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.agent
/**
* Провайдер одной секции system prompt конкретной беседы.
*
* Вызывается [MutableAgent] при каждом построении system prompt
* (на старте беседы и после значимых изменений контекста). Возвращает
* либо markdown-строку секции (будет вставлена в system prompt в порядке
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
* либо `null`, если у провайдера нет релевантной секции для данного
* контекста (например, skill-каталог пуст).
*
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
* страх и риск, **не** рекомендуется в v1).
*/
fun interface SystemPromptProvider {
/**
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
*
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
* если провайдер хочет делать per-conversation разделение, он может.
*/
fun getSection(conversationId: String): String
}
@@ -0,0 +1,33 @@
package pw.binom.agentik.agent
import pw.binom.litert.LiteTool
/**
* Провайдер набора тулов конкретной беседы.
*
* Вызывается [MutableAgent] при формировании списка тулов, доступных
* модели в данной беседе (на старте и при пересборке после существенных
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
* из `describe()` / `invoke()` того же объекта.
*
* Не-suspend: типичная реализация строит список тулов из in-memory state
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
* к state компонент использует свой собственный scope и кэш.
*
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
* лишним слоем и удалён.
*/
fun interface ToolProvider {
/**
* Возвращает список тулов, доступных модели в беседе [conversationId].
*
* Провайдер может делать per-conversation фильтрацию (например, скрывать
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
* пустой список.
*/
fun getTools(conversationId: String): List<LiteTool>
}
+3
View File
@@ -24,9 +24,12 @@ kotlin {
api(project(":journal-api")) api(project(":journal-api"))
api(project(":reflection-api")) api(project(":reflection-api"))
api(project(":context-api")) api(project(":context-api"))
api(project(":agent-api"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke) // litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api) api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
api(libs.litert.tools.kotlinx.serialization)
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core) api(libs.kotlinx.serialization.core)
@@ -1,5 +1,6 @@
package pw.binom.agentik.toolsets package pw.binom.agentik.toolsets
import kotlinx.serialization.Serializable
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
/** /**
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
*/ */
class DisableToolsetTool(private val registry: ToolsetRegistry) { class DisableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool( val tool: LiteTool = liteToolSuspend<DisableArgs>(
describeJson = DESCRIBE, name = NAME,
handler = ::invoke, description = "Deactivate a toolset by name. Its tools become unavailable.",
) ) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String { internal suspend fun invoke(args: DisableArgs): String {
val name = parseName(args) ?: return "missing required argument 'name'" val name = args.name
val toolset = registry.findByName(name) val toolset = registry.findByName(name)
if (toolset != null) { if (toolset != null) {
// Единообразный ответ независимо от текущего состояния.
registry.deactivate(name) registry.deactivate(name)
return "Toolset '$name' deactivated." return "Toolset '$name' deactivated."
} }
// Неизвестный — перечисляем активные (что можно деактивировать)
val actives = registry.activeNames() val actives = registry.activeNames()
return if (actives.isEmpty()) { return if (actives.isEmpty()) {
"Toolset '$name' not found. No toolsets to deactivate." "Toolset '$name' not found. No toolsets to deactivate."
@@ -39,11 +40,10 @@ class DisableToolsetTool(private val registry: ToolsetRegistry) {
} }
} }
@Serializable
internal data class DisableArgs(val name: String)
companion object { companion object {
const val NAME: String = "disable_toolset" const val NAME: String = "disable_toolset"
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
""".trimIndent()
} }
} }
@@ -1,8 +1,6 @@
package pw.binom.agentik.toolsets package pw.binom.agentik.toolsets
import kotlinx.serialization.json.Json import kotlinx.serialization.Serializable
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
/** /**
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
*/ */
class EnableToolsetTool(private val registry: ToolsetRegistry) { class EnableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool( val tool: LiteTool = liteToolSuspend<EnableArgs>(
describeJson = DESCRIBE, name = NAME,
handler = ::invoke, description = "Activate a toolset by name to access its tools.",
) ) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String { internal suspend fun invoke(args: EnableArgs): String {
val name = parseName(args) ?: return "missing required argument 'name'" val name = args.name
val toolset = registry.findByName(name) val toolset = registry.findByName(name)
if (toolset != null) { if (toolset != null) {
val wasActive = registry.isActive(name) val wasActive = registry.isActive(name)
registry.activate(name) registry.activate(name)
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated." return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
} }
// Неизвестный — перечисляем доступные к активации (inactives)
val inactives = registry.inactiveNames() val inactives = registry.inactiveNames()
return if (inactives.isEmpty()) { return if (inactives.isEmpty()) {
"Toolset '$name' not found. No toolsets available for activation." "Toolset '$name' not found. No toolsets available for activation."
@@ -42,23 +41,10 @@ class EnableToolsetTool(private val registry: ToolsetRegistry) {
} }
} }
@Serializable
internal data class EnableArgs(val name: String)
companion object { companion object {
const val NAME: String = "enable_toolset" const val NAME: String = "enable_toolset"
/**
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
* Соответствует litert-kmp формату LiteTool.describe().
*/
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
""".trimIndent()
} }
} }
/**
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
* Возвращает null если отсутствует или не строка.
*/
internal fun parseName(argsJson: String): String? = runCatching {
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
}.getOrNull()
@@ -1,15 +0,0 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* (имя-как-видит-модель) → [LiteTool].
*
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами.
*
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
* должен жить рядом с другими тулами в `:agent-toolsets`.
*/
data class NamedTool(val name: String, val tool: LiteTool)
@@ -2,9 +2,10 @@ package pw.binom.agentik.toolsets
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import pw.binom.litert.tools.kotlinx.serialization.liteTool
/** /**
* Адаптер из suspend-handler'а в синхронный [LiteTool]. * Обёртка из suspend-handler'а в синхронный [LiteTool].
* *
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это * `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт * упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам * `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов. * LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
* *
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
* который генерит JSON-схему из `@Serializable Args` через
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
* тулов, чьи описания не получается выразить через `Args` (например, динамические
* JSON Schema, приходящие со стороны).
*
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать * Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста. * `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
*/ */
internal class SyncLiteTool( internal class SyncLiteTool(
override val name: String,
private val describeJson: String, private val describeJson: String,
private val handler: suspend (String) -> String, private val handler: suspend (String) -> String,
) : LiteTool { ) : LiteTool {
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
} }
/** /**
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика. * Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено * JSON-схема генерится автоматически из `Args.descriptor`,
* как API-точка чтобы внешний код не зависел от internal-имени класса. * а сырая строка аргументов десериализуется в типизированный [Args].
*
* Использование:
* ```
* val t: LiteTool = liteToolSuspend<MyArgs>(name = "foo", description = "...") { args ->
* suspendBlock(args) // MyArgs уже распарсен
* }
* ```
*
* Реализация: под капотом используется [pw.binom.litert.tools.kotlinx.serialization.liteTool] —
* его sync-handler запускает наш suspend-handler в [runBlocking].
*/ */
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool = inline fun <reified Args> liteToolSuspend(
SyncLiteTool(describeJson, handler) name: String,
description: String = "",
noinline handler: suspend (Args) -> String,
): LiteTool = liteTool<Args>(
name = name,
description = description,
) { args ->
runBlocking { handler(args) }
}
@PublishedApi
internal val invocationJson: kotlinx.serialization.json.Json = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
isLenient = false
coerceInputValues = true
explicitNulls = false
}
@@ -0,0 +1,103 @@
package pw.binom.agentik.toolsets
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.litert.LiteTool
/**
* Подключает механику toolsets к агенту:
* - [ToolsetRegistry] (per-component instance — раньше жил в ChatAgent).
* - Тулы [EnableToolsetTool] и [DisableToolsetTool] всегда доступны — модель
* ими переключает состояние.
* - Тулы активных тулсетов — динамически: после `enable_toolset(name=X)`
* X.tools становятся видны через [ToolProvider.getTools] уже на
* следующем turn'е.
* - Секция системного промпта — список активных/неактивных тулсетов,
* чтобы модель знала что включено.
*
* Один [ToolsetComponent] на агента. Шарится между беседами через общий
* [MutableAgent] (все conversations читают один [ToolsetRegistry]).
*
* `install(agent)` идемпотентно. `uninstall(agent)` снимает оба провайдера
* по типу (см. [ToolsetToolProvider], [ToolsetSystemProvider]).
*/
class ToolsetComponent(
private val contributions: List<ToolsetContribution>,
) : Component {
/**
* Реестр тулсетов, владеет [ToolsetComponent]. `private` — наружу не светится,
* чтобы никто не дёргал его мимо `enable_toolset`/`disable_toolset` тулов.
*/
private val registry: ToolsetRegistry = ToolsetRegistry(contributions)
private var provider: ToolsetToolProvider? = null
override fun install(agent: MutableAgent) {
val p = ToolsetToolProvider(registry, contributions)
agent.toolProviders.add(p)
provider = p
agent.systemProviders.add(ToolsetSystemProvider(registry))
}
override fun uninstall(agent: MutableAgent) {
provider?.let { agent.toolProviders.remove(it) }
agent.systemProviders.removeAll { it is ToolsetSystemProvider }
}
}
/**
* Возвращает тулсет-тулы в зависимости от текущего состояния реестра:
* - `enable_toolset` / `disable_toolset` — всегда.
* - Тулы активных тулсетов — те, что перечислены в [ToolsetRegistry.activeNames].
*
* Snapshot собирается на каждом вызове [getTools] — диспетчер видит свежее
* состояние после `enable_toolset` уже на следующем turn'е.
*/
class ToolsetToolProvider(
private val registry: ToolsetRegistry,
private val contributions: List<ToolsetContribution>,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> = buildList {
add(EnableToolsetTool(registry).tool)
add(DisableToolsetTool(registry).tool)
// Активные тулсеты — добавляем их тулы в общий пул. Это синхронная
// версия (lock-free snapshot), потому что `getTools` вызывается
// синхронно из `collectTools()`; `active` сам по себе Concurrent-Set
// через Mutex в реестре (все мутации — через activate/deactivate).
val active = runBlockingSnapshot()
contributions.filter { it.name in active }.forEach { c ->
c.tools.forEach { add(it.tool) }
}
}
/**
* Снимает снимок активных имён без suspend-блокировки.
* ToolsetRegistry.activeNames() — suspend, но его можно обойти если
* вычислить через прямой snapshot — для простоты используем runBlocking.
* Это всё равно вызывается на каждый turn, но мьютекс короткий.
*/
private fun runBlockingSnapshot(): Set<String> = kotlinx.coroutines.runBlocking {
registry.activeNames().toSet()
}
}
/**
* Секция системного промпта с описанием доступных тулсетов:
* - `*active*` — что уже подключено.
* - `*inactive*` — что доступно через `enable_toolset`.
*/
class ToolsetSystemProvider(
private val registry: ToolsetRegistry,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String {
val activeNames = kotlinx.coroutines.runBlocking { registry.activeNames() }.toSet()
val all = registry.all()
val active = all.filter { it.name in activeNames }
val inactive = all.filter { it.name !in activeNames }
return SystemPromptToolsetSection.render(active = active, inactive = inactive) ?: ""
}
}
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class DisableToolsetToolTest { class DisableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok" override fun invoke(arguments: String) = "ok"
} }
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
reg.activate("media") reg.activate("media")
val r = disable.invoke("""{"name":"media"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
assertEquals("Toolset 'media' deactivated.", r) assertEquals("Toolset 'media' deactivated.", r)
assertEquals(false, reg.isActive("media")) assertEquals(false, reg.isActive("media"))
} }
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf( val (disable, _) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
// тулсет изначально неактивен — должно быть тот же ответ (uniform) val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
val r = disable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' deactivated.", r) assertEquals("Toolset 'media' deactivated.", r)
} }
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
)) ))
reg.activate("a") reg.activate("a")
reg.activate("b") reg.activate("b")
val r = disable.invoke("""{"name":"unknown"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r) assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
} }
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf( val (disable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()), ToolsetContribution("a", "x", emptyList()),
)) ))
val r = disable.invoke("""{"name":"unknown"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r) assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
} }
@Test
fun `missing name argument returns error message`() = runTest {
val (disable, _) = harness(emptyList())
val r = disable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
} }
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class EnableToolsetToolTest { class EnableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok" override fun invoke(arguments: String) = "ok"
} }
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
val (enable, reg) = harness(listOf( val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))), ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
)) ))
val r = enable.invoke("""{"name":"media"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' activated.", r) assertEquals("Toolset 'media' activated.", r)
assertEquals(true, reg.isActive("media")) assertEquals(true, reg.isActive("media"))
} }
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
reg.activate("media") reg.activate("media")
val r = enable.invoke("""{"name":"media"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' already active.", r) assertEquals("Toolset 'media' already active.", r)
} }
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
ToolsetContribution("b", "y", emptyList()), ToolsetContribution("b", "y", emptyList()),
ToolsetContribution("c", "z", emptyList()), ToolsetContribution("c", "z", emptyList()),
)) ))
val r = enable.invoke("""{"name":"unknown"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r) assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
} }
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
ToolsetContribution("a", "x", emptyList()), ToolsetContribution("a", "x", emptyList()),
)) ))
reg.activate("a") reg.activate("a")
val r = enable.invoke("""{"name":"unknown"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r) assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
} }
@Test
fun `missing name argument returns error message`() = runTest {
val (enable, _) = harness(emptyList())
val r = enable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
} }
@@ -11,6 +11,7 @@ import kotlin.test.assertTrue
class ToolsetDispatchPolicyTest { class ToolsetDispatchPolicyTest {
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool { private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response override fun invoke(arguments: String) = response
} }
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
class ToolsetRegistryTest { class ToolsetRegistryTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok:$name" override fun invoke(arguments: String) = "ok:$name"
} }
@@ -5,7 +5,7 @@ import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import kotlin.time.Instant import kotlin.time.Instant
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import kotlinx.cli.vararg import kotlinx.cli.vararg
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -10,7 +11,8 @@ import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Content import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.content.Content
import kotlin.time.Instant import kotlin.time.Instant
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") { class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
@@ -24,8 +26,8 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
return@runBlocking return@runBlocking
} }
try { try {
// Подписываемся на поток событий ДО send: события, отправленные // Durable-поток (End/Interrupted/Error + Tool*) — ловит терминатор хода.
// до подписки, не реплеятся (shared-flow без replay). // Подписываемся ДО send: события, отправленные до подписки, не реплеятся.
val eventsJob = launch { val eventsJob = launch {
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id) agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
// onEach печатает и терминальный event, takeWhile лишь // onEach печатает и терминальный event, takeWhile лишь
@@ -35,10 +37,19 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
.takeWhile { ev -> !isTerminal(ev) } .takeWhile { ev -> !isTerminal(ev) }
.collect { } .collect { }
} }
// Даём SSE-подписке установиться, затем шлём ход. // Онлайн-поток (дельты стриминга ответа) — live-only, без терминатора.
val onlineJob = launch {
agent.onlineOutbox.onlineEvents(conv.id)
.onEach { ev -> emitOnline(ev) }
.collect { }
}
// Даём SSE-подпискам установиться, затем шлём ход.
delay(200) delay(200)
conv.send(listOf(Content.Text(text.joinToString(" ")))) conv.send(listOf(Content.Text(text.joinToString(" "))))
eventsJob.join() eventsJob.join()
// Даём онлайн-потоку дослать хвостовые дельты, эмитнутые до End.
delay(100)
onlineJob.cancel()
} finally { } finally {
conv.close() conv.close()
} }
@@ -49,10 +60,6 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
private fun emit(ev: Event) { private fun emit(ev: Event) {
when (ev) { when (ev) {
is Event.StartReasoning -> println("event StartReasoning")
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}") is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}") is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
is Event.End -> println("event End") is Event.End -> println("event End")
@@ -61,5 +68,14 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
} }
} }
private fun emitOnline(ev: OnlineEvent) {
when (ev) {
is OnlineEvent.StartReasoning -> println("event StartReasoning")
is OnlineEvent.StartResponse -> println("event StartResponse ${ev.responseType}")
is OnlineEvent.AppendText -> println("event AppendText ${escape(ev.body)}")
is OnlineEvent.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
}
}
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r") private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
} }
@@ -5,9 +5,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -34,6 +35,9 @@ internal class TuiBackend(
/** Активная джоба подписки на [Conversation.events]. */ /** Активная джоба подписки на [Conversation.events]. */
private var eventsJob: Job? = null private var eventsJob: Job? = null
/** Активная джоба подписки на онлайн-поток (стриминг ответа). */
private var onlineJob: Job? = null
/** Последний виденный момент событий — для переподписки при reconnect. */ /** Последний виденный момент событий — для переподписки при reconnect. */
private var lastSeenAt: Instant = Instant.DISTANT_PAST private var lastSeenAt: Instant = Instant.DISTANT_PAST
@@ -92,41 +96,42 @@ internal class TuiBackend(
} }
/** /**
* Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state]. * Подписывается на durable-поток `outbox.conversationEvents(after, conv.id)`
* и live-поток `onlineOutbox.onlineEvents(conv.id)`; оба перенаправляет в [state].
*
* Онлайн-поток live-only (без catchup), поэтому подписку открываем ДО [Conversation.send]
* (см. [ensureConversation] → [onUserMessage]), чтобы не упустить начало хода.
*/ */
private fun subscribeEvents(conv: Conversation, from: Instant) { private fun subscribeEvents(conv: Conversation, from: Instant) {
eventsJob?.cancel() eventsJob?.cancel()
eventsJob = scope.launch { eventsJob = scope.launch {
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) } agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
} }
onlineJob?.cancel()
onlineJob = scope.launch {
agent.onlineOutbox.onlineEvents(conv.id).collect { ev -> dispatchOnline(ev) }
}
} }
/** /**
* Маппинг [Event] → [AppState] (что показать в TUI). * Маппинг [Event] (durable) → [AppState] (что показать в TUI).
* *
* - AppendText → дописывает в последний ассистентский чанк
* - StartReasoning / StartResponse → новый streaming-чанк
* - End → закрывает streaming * - End → закрывает streaming
* - Interrupted → закрывает streaming + системное сообщение * - Interrupted → закрывает streaming + системное сообщение
* - ToolCall / ToolResult → сообщения в историю * - ToolCall / ToolResult → сообщения в историю
* - Error → системное сообщение * - Error → системное сообщение
*
* Стриминг ответа (дельты текста/картинок) приходит отдельным потоком —
* см. [dispatchOnline].
*/ */
private fun dispatch(ev: Event) { private fun dispatch(ev: Event) {
lastSeenAt = ev.date lastSeenAt = ev.date
when (ev) { when (ev) {
is Event.AppendText -> state.appendAssistant(ev.body)
is Event.StartReasoning -> {
state.postSystem("… думаю")
}
is Event.StartResponse -> state.setStreaming(true)
is Event.End -> state.finishAssistant() is Event.End -> state.finishAssistant()
is Event.Interrupted -> { is Event.Interrupted -> {
state.finishAssistant() state.finishAssistant()
state.postSystem("прервано") state.postSystem("прервано")
} }
is Event.AppendImage -> {
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
}
is Event.ToolCall -> { is Event.ToolCall -> {
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs) state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
} }
@@ -139,4 +144,20 @@ internal class TuiBackend(
} }
} }
} }
/**
* Маппинг [OnlineEvent] (стриминг ответа, live-only) → [AppState].
*
* - AppendText → дописывает в последний ассистентский чанк
* - StartReasoning / StartResponse → новый streaming-чанк
* - AppendImage → системное сообщение-заглушка
*/
private fun dispatchOnline(ev: OnlineEvent) {
when (ev) {
is OnlineEvent.AppendText -> state.appendAssistant(ev.body)
is OnlineEvent.StartReasoning -> state.postSystem("… думаю")
is OnlineEvent.StartResponse -> state.setStreaming(true)
is OnlineEvent.AppendImage -> state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
}
}
} }
@@ -7,11 +7,12 @@ import pw.binom.agentik.journal.ConversationStore
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.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.content.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -23,6 +24,7 @@ internal class FakeAgent(
private val conversationFactory: () -> FakeConversation = { FakeConversation() }, private val conversationFactory: () -> FakeConversation = { FakeConversation() },
) : Agent { ) : Agent {
override val id: String = "fake" override val id: String = "fake"
override val info: AgentInfo = AgentInfo(name = "fake")
var createCount: Int = 0 var createCount: Int = 0
private set private set
val conversations = mutableListOf<FakeConversation>() val conversations = mutableListOf<FakeConversation>()
@@ -3,7 +3,7 @@ package pw.binom.agentik.tui
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
+4 -2
View File
@@ -40,6 +40,8 @@ val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").t
// beforeEvaluate — поздно). // beforeEvaluate — поздно).
val moduleDescriptions: Map<String, String> = mapOf( val moduleDescriptions: Map<String, String> = mapOf(
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.", "proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
"content-api" to "agentik :content-api — общие типы содержимого сообщения (Content/MessageContext/MessageOrigin/TurnTokens) для :proto, :journal-api, :outbox-api.",
"outbox-api" to "agentik :outbox-api — durable (Event) и live-only (OnlineEvent) потоки событий диалога + OutboxStore/OnlineOutbox.",
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.", "skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.", "server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.", "client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
@@ -47,8 +49,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)."
+78 -34
View File
@@ -72,9 +72,11 @@ dependencies {
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.time.Clock import kotlin.time.Clock
@@ -87,20 +89,36 @@ fun main() = runBlocking {
token = "s3cret", // или null, если не нужен token = "s3cret", // или null, если не нужен
) )
// 2. Открыть диалог, отправить сообщение.
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет")))
// 3. Собирать streaming-ответ. // 2. Два независимых потока событий диалога:
conv.events(after = Clock.System.now()).collect { ev -> // durable (outbox) — целые события, с курсором после переподключения;
when (ev) { // online (OnlineOutbox) — стриминг ответа, только live (без курсора).
is Event.StartResponse -> println("[start]") launch {
is Event.AppendText -> print(ev.body) agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id)
is Event.End -> println("[end]") .collect { ce ->
when (val ev = ce.event) {
is Event.AssistantMessage -> println("[answer ready: ${ev.content}]")
is Event.Interrupted -> println("[interrupted]")
is Event.Error -> println("[error: ${ev.message}]") is Event.Error -> println("[error: ${ev.message}]")
else -> Unit else -> Unit
} }
} }
}
launch {
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
when (ev) {
is OnlineEvent.Working -> println("[working]")
is OnlineEvent.AppendText -> print(ev.body)
is OnlineEvent.StartResponse -> println("[start]")
is OnlineEvent.End -> println("\n[end]")
else -> Unit
}
}
}
// 3. Отправить ход (fire-and-forget — ответ придёт по подпискам выше).
conv.send(listOf(Content.Text("Привет")))
// 4. Чистый shutdown. // 4. Чистый shutdown.
conv.close() conv.close()
@@ -109,7 +127,14 @@ fun main() = runBlocking {
``` ```
**Это весь клиент.** `:server` сам хранит историю, контекст, события. **Это весь клиент.** `:server` сам хранит историю, контекст, события.
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь. Ты только получаешь два типизированных `Flow` и рендеришь как хочешь.
> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их
> можно перезапросить по курсору `after`. `OnlineEvent` (в
> `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/
> `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при
> обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-
> `Event.AssistantMessage` и/или ляжет в journal.
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто `HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
внутри `AgentikAgent`. Один вызов — один готовый `Agent`. внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
@@ -172,9 +197,11 @@ history.forEach { rec ->
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
val agent = AgentikAgent( val agent = AgentikAgent(
id = "agentik", id = "agentik",
@@ -183,16 +210,30 @@ val agent = AgentikAgent(
) )
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
conv.events(after = kotlin.time.Clock.System.now()).collect { ev -> // durable-поток (с курсором): terminal-события хода.
when (ev) { launch {
is Event.AppendText -> print(ev.body) // streaming чанки agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id)
is Event.End -> println("\n--- end ---") .collect { ce ->
is Event.Error -> error("agent error: ${ev.message}") when (ce.event) {
is Event.AssistantMessage -> println("\n--- answer ready ---")
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}")
else -> Unit else -> Unit
} }
} }
}
// online-поток (live-only): стриминг ответа.
launch {
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
when (ev) {
is OnlineEvent.AppendText -> print(ev.body) // streaming чанки
is OnlineEvent.End -> println("\n--- end ---")
else -> Unit
}
}
}
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
``` ```
## История с локальным кэшем ## История с локальным кэшем
@@ -207,8 +248,8 @@ conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
```kotlin ```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import kotlin.time.Instant import kotlin.time.Instant
class ChatSession( class ChatSession(
@@ -235,10 +276,11 @@ class ChatSession(
after = Instant.DISTANT_PAST, after = Instant.DISTANT_PAST,
).collect { cache.append(it) } ).collect { cache.append(it) }
} }
// 2. Live: на каждом `End` хода просим у сервера новые записи. // 2. Live: на каждом завершённом ходе (durable AssistantMessage)
// просим у сервера новые записи.
scope.launch { scope.launch {
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev -> agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce ->
if (ev is Event.End) { if (ce.event is Event.AssistantMessage) {
val newest = cache.let { val newest = cache.let {
// last-seen курсор — последний createdAt в кэше // last-seen курсор — последний createdAt в кэше
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
@@ -342,22 +384,24 @@ escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступе
появится в кэше через refresh-блок выше. появится в кэше через refresh-блок выше.
```kotlin ```kotlin
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.OnlineEvent
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev -> agent.onlineOutbox.onlineEvents(convId).collect { ev ->
when (ev) { when (ev) {
is Event.StartResponse -> println("[start]") is OnlineEvent.Working -> println("[working]")
is Event.AppendText -> print(ev.body) is OnlineEvent.StartResponse -> println("[start]")
is Event.AppendImage -> showImage(ev.body) is OnlineEvent.AppendText -> print(ev.body)
is Event.ToolCall -> println("[tool: ${ev.toolName}]") is OnlineEvent.AppendImage -> showImage(ev.body)
is Event.ToolResult -> println("[result]") is OnlineEvent.End -> println("[end]")
is Event.End -> println("[end]")
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit else -> Unit
} }
} }
``` ```
Инструментальные вызовы и целый ответ — durable-поток
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
## Прерывание хода ## Прерывание хода
```kotlin ```kotlin
@@ -444,7 +488,7 @@ agent.deleteConversation(conv.id) // → DELETE /conversations/
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:storage-ksqlite` как образец. `:journal-ksqlite` как образец.
```kotlin ```kotlin
import pw.binom.agentik.journal.MutableConversationStore import pw.binom.agentik.journal.MutableConversationStore
+1 -1
View File
@@ -44,7 +44,7 @@ kotlin {
implementation(libs.ktor.server.sse) implementation(libs.ktor.server.sse)
} }
jvmTest.dependencies { jvmTest.dependencies {
implementation("junit:junit:4.13.2") implementation(libs.junit)
} }
} }
@@ -14,8 +14,10 @@ import io.ktor.http.contentType
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant import kotlin.time.Instant
@@ -27,9 +29,16 @@ import kotlin.time.Instant
* **Storage handles** ([journal], [outbox], [conversationStore]) — read-only * **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
* views на серверные хранилища. Запись — только через команды * views на серверные хранилища. Запись — только через команды
* [createConversation] / [deleteConversation] / [renameConversation]. * [createConversation] / [deleteConversation] / [renameConversation].
*
* Конструируется через suspend [Companion.create], который **eagerly**
* фетчит [info] (`GET {baseUrl}`) и сохраняет снимок в поле. Это убирает
* необходимость в `lazy { runBlocking { ... } }` на горячем пути —
* `runBlocking` живёт один раз в [Companion.create], оттуда же [AgentikAgent]
* его и вызывает (там он приемлем: одноразовая инициализация агента).
*/ */
internal class AgentClient( internal class AgentClient private constructor(
override val id: String, override val id: String,
override val info: AgentInfo,
private val baseUrl: String, private val baseUrl: String,
private val httpClient: HttpClient, private val httpClient: HttpClient,
) : Agent { ) : Agent {
@@ -37,6 +46,7 @@ internal class AgentClient(
private val agentUrl: String = baseUrl.trimEnd('/') private val agentUrl: String = baseUrl.trimEnd('/')
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl) override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
override val onlineOutbox: OnlineOutbox = HttpOnlineOutbox(httpClient = httpClient, baseUrl = agentUrl)
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl) override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl) override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
@@ -74,4 +84,32 @@ internal class AgentClient(
override fun close() { override fun close() {
httpClient.close() httpClient.close()
} }
companion object {
/**
* Создаёт [AgentClient] и eagerly загружает [Agent.info]
* (`GET {baseUrl}` на серверном фасаде). Любой сбой сети на этом
* этапе пробрасывается как исключение — агент без `info` бесполезен
* (UI/A2A сразу упрутся в `agent.info`).
*
* Single-shot инициализация, `runBlocking` тут допустим (см. KDoc
* класса). Хосты, которым нужен полностью неблокирующий старт,
* могут обернуть вызов в свой `CoroutineScope`.
*/
suspend fun create(
id: String,
baseUrl: String,
httpClient: HttpClient,
): AgentClient {
val agentUrl = baseUrl.trimEnd('/')
val info: AgentInfo = httpClient.get(agentUrl).body()
return AgentClient(
id = id,
info = info,
baseUrl = agentUrl,
httpClient = httpClient,
)
} }
}
}
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
@@ -95,7 +96,7 @@ fun AgentikAgent(
token: String? = null, token: String? = null,
): Agent { ): Agent {
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token) val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient) val client = runBlocking { AgentClient.create(id = id, baseUrl = baseUrl, httpClient = httpClient) }
return wrapWithLocalConversationCache(client, scopeClient = client) return wrapWithLocalConversationCache(client, scopeClient = client)
} }
@@ -10,10 +10,10 @@ import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.content.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -59,6 +59,9 @@ internal class ConversationClient(
httpClient.post("$convUrl/messages") { httpClient.post("$convUrl/messages") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(SendPayload(content, context)) setBody(SendPayload(content, context))
// Сервер отвечает только по завершении хода агента (LLM + тулы),
// а это минуты, а не 15 секунд дефолтного request-timeout.
noReadTimeout()
} }
} }
@@ -33,7 +33,10 @@ internal class HttpConversationStore(
private val agentUrl: String = baseUrl.trimEnd('/') private val agentUrl: String = baseUrl.trimEnd('/')
override suspend fun get(id: String): ConversationRecord? { override suspend fun get(id: String): ConversationRecord? {
val response = httpClient.get("$agentUrl/conversations/$id") // `/record` (а НЕ `/conversations/{id}`): последний отдаёт
// ConversationSnapshot для `AgentClient.getConversation`, у которого
// другой shape (handle + isImageSupported, без createdAt/updatedAt).
val response = httpClient.get("$agentUrl/conversations/$id/record")
if (response.status == HttpStatusCode.NotFound) return null if (response.status == HttpStatusCode.NotFound) return null
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
"conversationStore.get($id): server returned ${response.status}" "conversationStore.get($id): server returned ${response.status}"
@@ -53,7 +53,7 @@ internal class HttpEventStore(
append("$agentUrl/outbox/events") append("$agentUrl/outbox/events")
if (after != null) append("?after=$after") if (after != null) append("?after=$after")
} }
httpClient.prepareGet(url) { noSseReadTimeout() } httpClient.prepareGet(url) { noReadTimeout() }
.execute { response -> .execute { response ->
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}" "events: server returned ${response.status}"
@@ -74,7 +74,7 @@ internal class HttpEventStore(
append("$agentUrl/events") append("$agentUrl/events")
if (after != null) append("?after=$after") if (after != null) append("?after=$after")
} }
httpClient.prepareGet(url) { noSseReadTimeout() } httpClient.prepareGet(url) { noReadTimeout() }
.execute { response -> .execute { response ->
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
"agentEvents: server returned ${response.status}" "agentEvents: server returned ${response.status}"
@@ -104,7 +104,7 @@ internal class HttpEventStore(
append("$agentUrl/conversations/$conversationId/events") append("$agentUrl/conversations/$conversationId/events")
if (after != null) append("?after=$after") if (after != null) append("?after=$after")
} }
httpClient.prepareGet(url) { noSseReadTimeout() } httpClient.prepareGet(url) { noReadTimeout() }
.execute { response -> .execute { response ->
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
"conversationEvents: server returned ${response.status}" "conversationEvents: server returned ${response.status}"
@@ -5,6 +5,7 @@ import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Instant import kotlin.time.Instant
@@ -13,8 +14,11 @@ import kotlin.time.Instant
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога), * HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
* ходящая в `:server`-фасад. * ходящая в `:server`-фасад.
* *
* **Endpoint**: `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=` * **Endpoints** (см. [pw.binom.agentik.server.journalRoutes]):
* (см. [pw.binom.agentik.server.journalRoutes]). * - `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
* → [list]
* - `GET {baseUrl}/journal/conversations/{id}/count` → [count] (total)
* - `GET {baseUrl}/journal/conversations/{id}/count?after=` → [count] (after cursor)
* *
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage / * Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages` * ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
@@ -54,7 +58,28 @@ internal class HttpJournalStore(
return response.body<List<MessageRecord>>() return response.body<List<MessageRecord>>()
} }
override suspend fun count(conversationId: String): Long {
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count")
check(response.status == HttpStatusCode.OK) {
"journal.count: server returned ${response.status}"
}
return response.body<CountResponse>().count
}
override suspend fun count(conversationId: String, after: Instant): Long {
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") {
parameter("after", after.toString())
}
check(response.status == HttpStatusCode.OK) {
"journal.count(after): server returned ${response.status}"
}
return response.body<CountResponse>().count
}
override fun close() { override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent). // HttpClient закрывает владелец (AgentClient / AgentikAgent).
} }
} }
@Serializable
private data class CountResponse(val count: Long)
@@ -0,0 +1,55 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.outbox.OnlineOutbox
/**
* HTTP-реализация [OnlineOutbox] (= [pw.binom.agentik.outbox.OnlineOutbox]),
* ходящая в `:server`-фасад.
*
* **Endpoints**:
* - [onlineEvents] (без аргумента) → `GET {baseUrl}/online` (live-only SSE,
* все диалоги агента);
* - [onlineEvents] с `conversationId` → `GET {baseUrl}/conversations/{id}/online`
* (live-only SSE, один диалог).
*
* Сервер не реплеит — подписка получает только то, что эмитится после
* подключения. Reconnect-логика здесь не нужна: при обрыве поток просто
* закрывается, а потерянные дельты восстанавливаются из durable-истории
* ([HttpEventStore] + журнал).
*/
internal class HttpOnlineOutbox(
private val httpClient: HttpClient,
private val baseUrl: String,
) : OnlineOutbox {
private val agentUrl: String = baseUrl.trimEnd('/')
override fun onlineEvents(): Flow<OnlineEvent> = stream("$agentUrl/online")
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
stream("$agentUrl/conversations/$conversationId/online")
private fun stream(url: String): Flow<OnlineEvent> = flow {
httpClient.prepareGet(url) { noReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"onlineEvents: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(OnlineEvent.serializer(), payload))
}
}
}
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@@ -8,18 +8,23 @@ import io.ktor.client.request.HttpRequestBuilder
* Отключает request/connect/socket-таймауты для конкретного запроса через * Отключает request/connect/socket-таймауты для конкретного запроса через
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS]. * [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
* *
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не * Зачем: два вида запросов живут дольше дефолтных 15 секунд:
* - **SSE-чтение** ([readSse]) — читает `bodyAsChannel()` руками и не
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным * использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет * ([HttpRequestBuilder.supportsRequestTimeout] проверяет
* `body is SSEClientContent`, а у нас тело — обычный GET без тела). * `body is SSEClientContent`, а у нас тело — обычный GET без тела).
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию * - **`POST /conversations/{id}/messages`** — сервер отвечает не сразу, а
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд. * только когда ход агента полностью завершён (LLM + тулы). Реальный ход
* легко длится минуты, и дефолтный request-timeout убивал бы его на
* 15-й секунде, обрывая ещё живой ход на сервере.
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis`
* (по умолчанию **15000 мс**) молча убивает такой запрос.
* *
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при * Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
* установленном capability мутирует его поля через `?:`, так что шаренный * установленном capability мутирует его поля через `?:`, так что шаренный
* инстанс мог бы утечь между запросами. * инстанс мог бы утечь между запросами.
*/ */
internal fun HttpRequestBuilder.noSseReadTimeout() { internal fun HttpRequestBuilder.noReadTimeout() {
setCapability( setCapability(
HttpTimeoutCapability, HttpTimeoutCapability,
HttpTimeoutConfig( HttpTimeoutConfig(
@@ -66,7 +66,7 @@ private fun testEvent(dateMs: Long): CommonEvent =
CommonEvent.Conversation( CommonEvent.Conversation(
date = Instant.fromEpochMilliseconds(dateMs), date = Instant.fromEpochMilliseconds(dateMs),
conversationId = "test", conversationId = "test",
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)), event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -26,7 +26,7 @@ import kotlin.test.fail
/** /**
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout] * Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability * = 15 с убивает SSE read. Наш fix — [noReadTimeout] ставит capability
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE * [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
* перед каждым read-стримом. * перед каждым read-стримом.
* *
@@ -43,7 +43,7 @@ class SseTimeoutTest {
private fun freePort(): Int = ServerSocket(0).use { it.localPort } private fun freePort(): Int = ServerSocket(0).use { it.localPort }
@Test @Test
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking { fun `sse read survives past default cio timeout with noReadTimeout`(): Unit = runBlocking {
val port = freePort() val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) { val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing { routing {
@@ -65,7 +65,7 @@ class SseTimeoutTest {
val received = mutableListOf<String>() val received = mutableListOf<String>()
client.prepareGet("http://127.0.0.1:$port/sse") { client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream") header("Accept", "text/event-stream")
noSseReadTimeout() noReadTimeout()
}.execute { resp -> }.execute { resp ->
val ch = resp.bodyAsChannel() val ch = resp.bodyAsChannel()
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с. // 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
@@ -95,14 +95,14 @@ class SseTimeoutTest {
} }
/** /**
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный * Контр-тест: убеждаемся что БЕЗ [noReadTimeout] дефолтный
* CIO requestTimeout = 15 с действительно убивает SSE-стрим. * CIO requestTimeout = 15 с действительно убивает SSE-стрим.
* Сервер держит stream 17 с; если клиент не выставил capability — * Сервер держит stream 17 с; если клиент не выставил capability —
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не * мы должны получить [HttpRequestTimeoutException] на ~15 с, не
* дожидаясь "done". * дожидаясь "done".
*/ */
@Test @Test
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking { fun `without noReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
val port = freePort() val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) { val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing { routing {
@@ -123,7 +123,7 @@ class SseTimeoutTest {
try { try {
client.prepareGet("http://127.0.0.1:$port/sse") { client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream") header("Accept", "text/event-stream")
// НАМЕРЕННО без noSseReadTimeout. // НАМЕРЕННО без noReadTimeout.
}.execute { resp -> }.execute { resp ->
val ch = resp.bodyAsChannel() val ch = resp.bodyAsChannel()
// Читаем строки, пока не придёт "data: done" — без capability // Читаем строки, пока не придёт "data: done" — без capability
+33
View File
@@ -0,0 +1,33 @@
# `:content-api` — общие типы содержимого сообщения
Низкоуровневый KMP-модуль с типами, которые используются во всех слоях
agentik и раньше дублировались:
- `Content` — часть содержимого сообщения: `Content.Text(body)`,
`Content.Image(data, mime)`.
- `MessageContext` — контекст инициации хода (`origin`, `description`,
`sourceId`, `metadata`).
- `MessageOrigin` — `USER` / `SYSTEM` / `EVENT`.
- `TurnTokens` — token usage одного assistant turn'а (`input`, `output`).
## Зачем отдельный модуль
`:proto` (wire-контракт), `:journal-api` (слой хранения) и `:outbox-api`
(события) должны ссылаться на **один и тот же** `Content`/`MessageContext`,
а не держать по собственной копии. Общий модуль убирает дубли и циклы:
```
:content-api ◄── :proto
◄── :journal-api
◄── :outbox-api
```
`:proto`/`:journal-api`/`:outbox-api` объявляют `api(project(":content-api"))`,
поэтому потребители (`:client`, `:server`, `:standalone`, ...) видят типы
транзитивно, но должны импортировать их напрямую из `pw.binom.agentik.content`.
## Публикация
Каталог `gradle/libs.versions.toml` → `agentik-content-api`.
`./gradlew :content-api:publish -Pversion=...` публикует все KMP-таргеты
(jvm + натив).
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
// для JsonElement в MessageContext.metadata
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,31 @@
package pw.binom.agentik.content
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Часть содержимого сообщения (пользовательского или агентского).
*
* Единый тип для всего проекта: используется и в wire-контракте ([pw.binom.agentik.proto]),
* и в слое хранения ([pw.binom.agentik.journal]), и в durable-событиях
* ([pw.binom.agentik.outbox.Event]). Вынесен в отдельный модуль `:content-api`,
* чтобы не дублировать его в каждом слое и не заводить циклов в графе.
*/
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
data class Text(val body: String) : Content
/**
* Картинка. [mime] — MIME-тип, например `"image/png"`, `"image/jpeg"`.
*/
@Serializable
@SerialName("image")
data class Image(val data: ByteArray, val mime: String) : Content {
override fun equals(other: Any?): Boolean =
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
}
}
@@ -0,0 +1,52 @@
package pw.binom.agentik.content
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
*
* Примеры:
* ```
* // cron-задача утренней сводки
* MessageContext(
* origin = MessageOrigin.EVENT,
* description = "scheduled cron 'morning-briefing'",
* sourceId = "cron-42",
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
* )
*
* // обычное сообщение из IRC
* MessageContext(
* origin = MessageOrigin.USER,
* sourceId = "irc-channel:agentik",
* description = "PRIVMSG from nick",
* )
* ```
*
* Семантический контракт:
* - origin != USER ⇒ [description] обязателен и должен быть человекочитаемым.
* - origin == USER ⇒ context может быть `null` (дефолт).
*
* Снапшот-стабильность wire-формата: поля сериализуются по именам, snake_case
* на enum'е [MessageOrigin] даёт `user`/`system`/`event`. Новые поля —
* non-breaking для старых клиентов.
*/
@Serializable
data class MessageContext(
val origin: MessageOrigin,
/**
* Короткая человекочитаемая фраза для LLM: попадает в working memory
* как префикс `[origin] description (sourceId=…)` к user-сообщению.
*/
val description: String? = null,
/**
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC.
*/
val sourceId: String? = null,
/**
* Произвольный структурированный payload о событии. Никогда не попадает
* в LLM-нагрузку как сырой JSON — только логирование и пост-аналитика.
*/
val metadata: JsonElement? = null,
)
@@ -0,0 +1,19 @@
package pw.binom.agentik.content
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Кто/что инициировал ход (кто/что и почему).
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.journal package pw.binom.agentik.content
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -11,6 +11,7 @@ data class TurnTokens(
val output: Int, val output: Int,
) { ) {
val total: Int get() = input + output val total: Int get() = input + output
init { init {
require(input >= 0) { "input tokens must be non-negative, got $input" } require(input >= 0) { "input tokens must be non-negative, got $input" }
require(output >= 0) { "output tokens must be non-negative, got $output" } require(output >= 0) { "output tokens must be non-negative, got $output" }
@@ -1,4 +1,4 @@
package pw.binom.agentik.proto package pw.binom.agentik.content
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -2,8 +2,8 @@ package pw.binom.agentik.context
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.MessageContext import pw.binom.agentik.content.MessageContext
/** /**
* Запись в working memory диалога: ровно то, что агент сейчас видит в * Запись в working memory диалога: ровно то, что агент сейчас видит в
+3 -3
View File
@@ -10,7 +10,7 @@ plugins {
// агента. // агента.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -21,9 +21,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный // ksqlite живёт в libs.versions.toml (см. catalog) — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет. // `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2") implementation(libs.ksqlite)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
api(project(":context-api")) api(project(":context-api"))
@@ -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
@@ -2,7 +2,7 @@ package pw.binom.agentik.context.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
@@ -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)
} }
+19 -12
View File
@@ -47,28 +47,35 @@ agentik
## 3. `:proto` — интерфейсы ## 3. `:proto` — интерфейсы
`Agent` (см. `proto/src/commonMain/.../Agent.kt`): `Agent` (см. `proto/src/commonMain/.../Agent.kt`):
- `id: String` - `id: String`, `info: AgentInfo`
- read-only сторы: `journal: JournalStore`, `outbox: OutboxStore`,
`onlineOutbox: OnlineOutbox`, `conversationStore: ConversationStore`
- `createConversation(temp: Boolean): Conversation` - `createConversation(temp: Boolean): Conversation`
- `suspend getConversation(id): Conversation?` - `suspend getConversation(id): Conversation?`
- `suspend getConversations(offset, limit): List<Conversation>` - `suspend deleteConversation(id): Boolean`
- `getConversations(offset = 0): Flow<Conversation>` — cold-flow paging через suspend-версию, `PAGE_SIZE = 100`. - `suspend renameConversation(id, title): Instant?`
- `events(after: Instant): Flow<AgentEvent>` — replay-free, бэкфилл через snapshot.
- `deleteConversation(id): Boolean`
`Conversation`: `Conversation`:
- `isSupportImageInput / Output / isTemporal: Boolean` - `isSupportImageInput / Output / isTemporal: Boolean`, `title: String?`
- `updatedAt: Instant` - `updatedAt: Instant`
- `send(content: List<Content>)` — write-only, ничего не возвращает. - `send(content: List<Content>, context: MessageContext? = null)` — write-only, ничего не возвращает.
- `interrupt()` — отмена активного хода. - `interrupt()` — отмена активного хода.
- `events(after): Flow<Event>` — live, replay-free. - `getMessages(after, offset, limit): List<Message>` — paging.
- `getMessages(after, offset, limit)` + `getMessages(after): Flow<Message>` — paging.
- `rename(title)` — мутация, бампит `updatedAt`. - `rename(title)` — мутация, бампит `updatedAt`.
- `AutoCloseable` — `close()` идемпотентен. - `AutoCloseable` — `close()` идемпотентен.
`Content = Text(body) | Image(data, mime)`. `Content = Text(body) | Image(data, mime)` — из `:content-api` (там же `MessageContext`/`MessageOrigin`/`TurnTokens`).
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`. `Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
`Event = StartReasoning | StartResponse | End | AppendText | AppendImage | ToolCall | ToolResult | Error`.
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title)`. События разделены на два потока (оба в `:outbox-api`):
- **durable** `Event` (`outbox.conversationEvents(after, id)`): `UserMessage |
AssistantMessage | ToolCall | ToolResult | ToolFailed | Interrupted | Error |
ConversationClosing | CompactionTriggered` — перезапрашиваются по курсору;
- **online** `OnlineEvent` (`onlineOutbox.onlineEvents(id)`): `Working | End |
StartReasoning | StartResponse | AppendText | AppendImage` — live-only,
никогда не сохраняются.
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title) | Touched(...)`.
Принцип: **агент — источник истины** для транскрипта и сессий. Клиент Принцип: **агент — источник истины** для транскрипта и сессий. Клиент
лишь рендерит Event-stream и кэширует историю. лишь рендерит Event-stream и кэширует историю.
+6 -5
View File
@@ -63,9 +63,9 @@
│ 3. ensureLiteConversation: │ │ 3. ensureLiteConversation: │
│ first turn → create from WM; │ │ first turn → create from WM; │
│ next turns → reuse (KV-cache) │ │ next turns → reuse (KV-cache) │
│ 4. sendStreamContents → emit │ │ 4. sendStreamContents → online: │
│ StartResponse / AppendText / │ │ StartResponse/AppendText/End; │
│ End │ │ durable: AssistantMessage │
│ 5. audit + WM: append AssistantMessage│ │ 5. audit + WM: append AssistantMessage│
└──────────────────────────────────────┘ └──────────────────────────────────────┘
│ │ │ │
@@ -151,7 +151,8 @@ fun main() {
| `updatedAt: Instant` | последний `send`/`rename` | | `updatedAt: Instant` | последний `send`/`rename` |
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти | | `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
| `interrupt()` | остановить текущий ход (best-effort) | | `interrupt()` | остановить текущий ход (best-effort) |
| `events(after): Flow<Event>` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) | | `outbox.conversationEvents(after, id): Flow<Event>` | durable-события (UserMessage, AssistantMessage, ToolCall/Result, Interrupted, Error) — скурсором |
| `onlineOutbox.onlineEvents(id): Flow<OnlineEvent>` | live-only стриминг (Working, End, StartReasoning, StartResponse, AppendText, AppendImage) |
| `getMessages(after, offset, limit)` | страница истории | | `getMessages(after, offset, limit)` | страница истории |
| `rename(title)` | переименовать | | `rename(title)` | переименовать |
| `close()` | освободить ресурсы | | `close()` | освободить ресурсы |
@@ -173,7 +174,7 @@ fun main() {
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`. Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов). **Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит durable `Event.Error` + онлайн `OnlineEvent.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
### `JournalStore` ### `JournalStore`
+16 -2
View File
@@ -6,7 +6,7 @@ kotlinx-io = "0.8.0"
ktor = "3.1.3" ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT" a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0" kaml = "0.104.0"
litert = "8" litert = "13"
sqldelight = "2.3.2" sqldelight = "2.3.2"
shadow = "8.3.5" shadow = "8.3.5"
jvector = "3.0.6" jvector = "3.0.6"
@@ -16,6 +16,8 @@ logback = "1.5.18"
mosaic = "0.18.0" mosaic = "0.18.0"
clikt = "5.0.3" clikt = "5.0.3"
kotlinx-cli = "0.3.6" kotlinx-cli = "0.3.6"
ksqlite = "0.1.4"
junit = "4.13.2"
[plugins] [plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
@@ -40,6 +42,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" }
@@ -93,6 +98,14 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется --- # --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
# kmmio-core = { module = "dev.karmakrafts.kmmio:kmmio-core", version = "2.3.1" } # kmmio-core = { module = "dev.karmakrafts.kmmio:kmmio-core", version = "2.3.1" }
# --- ksqlite (pw.binom.db) — KMP SQLite со встроенным sqlite-vec (https://github.com/caffeine-mgn/ksqlite).
# Тянется как обычная `implementation(libs.ksqlite)` — Gradle Module Metadata резолвит per-target
# variant (ksqlite-jvm / ksqlite-linuxx64 / ksqlite-mingwx64 / ...).
ksqlite = { module = "pw.binom.db:ksqlite", version.ref = "ksqlite" }
# --- JUnit 4 — legacy test framework (используется в :client для совместимости с ktor-server-test-host). ---
junit = { module = "junit:junit", version.ref = "junit" }
# --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. --- # --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. ---
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" } jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
@@ -109,5 +122,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" }
+1
View File
@@ -17,6 +17,7 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
api(project(":content-api"))
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core) api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json) api(libs.kotlinx.serialization.json)
@@ -1,24 +0,0 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
* в `Mapping.kt` storage impl'ов.
*/
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
data class Text(val body: String) : Content
@Serializable
@SerialName("image")
data class Image(val data: ByteArray, val mime: String) : Content {
override fun equals(other: Any?): Boolean =
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
}
}
@@ -1,10 +1,12 @@
package pw.binom.agentik.journal package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Snapshot диалога. В таблице `conversation` хранится как есть. * Snapshot диалога. В таблице `conversation` хранится как есть.
*/ */
@Serializable
data class ConversationRecord( data class ConversationRecord(
val id: String, val id: String,
val title: String?, val title: String?,
@@ -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()` пока не получит короткую страницу). Для
@@ -1,12 +0,0 @@
package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class MessageContext(
val origin: MessageOrigin,
val sourceId: String? = null,
val description: String? = null,
val metadata: JsonElement? = null,
)
@@ -1,20 +0,0 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Контекст инициации хода (кто/что и почему). Дубликат типа из `:proto` —
* живёт здесь чтобы не тащить `:proto` в слой хранения данных.
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@@ -2,6 +2,9 @@ package pw.binom.agentik.journal
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -36,6 +39,12 @@ sealed interface MessageRecord {
override val content: List<Content>, override val content: List<Content>,
override val createdAt: Instant, override val createdAt: Instant,
val tokens: TurnTokens? = null, val tokens: TurnTokens? = null,
/**
* Текст размышлений модели (chain-of-thought / reasoning), если провайдер
* его отдаёт. Опционально: null, если размышлений не было или провайдер
* их не раскрывает.
*/
val reasoning: String? = null,
) : Body ) : Body
@Serializable @Serializable
@@ -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)
} }
@@ -4,6 +4,9 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
private val bodyJson = Json { private val bodyJson = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -17,15 +20,17 @@ data class MessageBodyPayload(
@SerialName("context") @SerialName("context")
val context: MessageContext? = null, val context: MessageContext? = null,
val tokens: TurnTokens? = null, val tokens: TurnTokens? = null,
val reasoning: String? = null,
) )
fun encodeBodyPayload( fun encodeBodyPayload(
content: List<Content>, content: List<Content>,
context: MessageContext? = null, context: MessageContext? = null,
tokens: TurnTokens? = null, tokens: TurnTokens? = null,
reasoning: String? = null,
): String = bodyJson.encodeToString( ): String = bodyJson.encodeToString(
MessageBodyPayload.serializer(), MessageBodyPayload.serializer(),
MessageBodyPayload(content = content, context = context, tokens = tokens), MessageBodyPayload(content = content, context = context, tokens = tokens, reasoning = reasoning),
) )
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json) fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
@@ -34,14 +39,15 @@ data class BodyDecoded(
val content: List<Content>, val content: List<Content>,
val context: MessageContext?, val context: MessageContext?,
val tokens: TurnTokens? = null, val tokens: TurnTokens? = null,
val reasoning: String? = null,
) )
private fun readPayload(json: String): BodyDecoded { private fun readPayload(json: String): BodyDecoded {
return try { return try {
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json) val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
BodyDecoded(p.content, p.context, p.tokens) BodyDecoded(p.content, p.context, p.tokens, p.reasoning)
} catch (e: kotlinx.serialization.SerializationException) { } catch (e: kotlinx.serialization.SerializationException) {
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json) val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
BodyDecoded(arr, null, null) BodyDecoded(arr, null, null, null)
} }
} }
@@ -54,9 +54,17 @@ class InMemoryJournalStore : MutableJournalStore {
.toList() .toList()
} }
/** Сбросить кэш (например, когда диалог удалён). */ /** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
suspend fun clear(): Unit = mutex.withLock { override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.clear() records.removeAll { it.conversationId == conversationId }
}
override suspend fun count(conversationId: String): Long = mutex.withLock {
records.count { it.conversationId == conversationId }.toLong()
}
override suspend fun count(conversationId: String, after: Instant): Long = mutex.withLock {
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
} }
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */ /** Сколько записей сейчас в кэше. Для тестов/диагностики. */
@@ -29,7 +29,7 @@ class InMemoryMutableConversationStore(
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) : MutableConversationStore { ) : MutableConversationStore {
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf() private val byId = mutableMapOf<String, ConversationRecord>()
private val mutex = Mutex() private val mutex = Mutex()
override suspend fun upsert(record: ConversationRecord) { override suspend fun upsert(record: ConversationRecord) {
@@ -14,7 +14,7 @@ class InMemoryJournalStoreTest {
MessageRecord.UserMessage( MessageRecord.UserMessage(
id = id, id = id,
conversationId = convId, conversationId = convId,
content = listOf(pw.binom.agentik.journal.Content.Text(text)), content = listOf(pw.binom.agentik.content.Content.Text(text)),
createdAt = at, createdAt = at,
) )
@@ -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),
)
} }
} }
+3 -3
View File
@@ -9,7 +9,7 @@ plugins {
// собственных ksqlite-модулях. // собственных ksqlite-модулях.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -20,9 +20,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный // ksqlite живёт в libs.versions.toml (см. catalog) — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет. // `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2") implementation(libs.ksqlite)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
api(project(":journal-api")) api(project(":journal-api"))
@@ -14,31 +14,67 @@ import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [MutableJournalStore] (append-only audit log). * ksqlite-реализация [MutableJournalStore] (append-only audit log).
* *
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore] * Единственный класс для message-таблицы. Используется напрямую через
* из `:storage-ksqlite`, но: * `:journal-ksqlite` зависимость; bundle'ом собирает
* - лежит в собственном модуле `:journal-ksqlite`; * `pw.binom.agentik.standalone.persistence.SqliteStores`.
* - реализует переименованный [MutableJournalStore] (раньше был *
* `MutableMessageStore`, теперь главный класс — `JournalStore` / * ## Lifecycle соединения
* `MutableJournalStore`); сам тип записи [MessageRecord] не *
* переименовывался. * Три формы конструктора с разной семантикой владения:
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
* где один connection используется многими store'ами и закрывается bundle'ом.
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
* его в [close].
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
* закрывает его в [close].
*
* ## Миграция
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
* прогоняют миграцию одной и той же схемы по очереди.
* *
* Prepared statements (insert / list / clear) препарируются один раз в * Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого * конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже * GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]). * parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
* (см. [pw.binom.db.ksqlite.StmtHolder]).
* *
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding * `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат * helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом. * в [MessageCodecs.kt] рядом.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
* отдельно.
*/ */
class KsqliteJournalStore internal constructor( class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableJournalStore { ) : MutableJournalStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`), где один
* connection используется многими store'ами и закрывается bundle'ом.
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -64,6 +100,16 @@ class KsqliteJournalStore internal constructor(
private val clearStmt: SQLitePreparedStatement = connection.prepare( private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?" "DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
) )
private val countAllStmt: SQLitePreparedStatement = connection.prepare(
"SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val countAfterStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
""".trimIndent()
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) { override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record) val (kind, payload) = encodeRecord(record)
@@ -94,13 +140,15 @@ class KsqliteJournalStore internal constructor(
listStmt.bindLong(4, offset.toLong()) listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>() val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs -> listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json)) while (rs.next()) {
out.add(rs.toMessageRecord(json))
}
} }
out out
} }
} }
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) { override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
clearStmt.reset() clearStmt.reset()
clearStmt.clearBindings() clearStmt.clearBindings()
@@ -109,9 +157,52 @@ class KsqliteJournalStore internal constructor(
} }
} }
override suspend fun count(conversationId: String): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAllStmt.reset()
countAllStmt.clearBindings()
countAllStmt.bindText(1, conversationId)
countAllStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override suspend fun count(conversationId: String, after: Instant): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAfterStmt.reset()
countAfterStmt.clearBindings()
countAfterStmt.bindText(1, conversationId)
countAfterStmt.bindLong(2, after.toEpochMilliseconds())
countAfterStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override fun close() { override fun close() {
insertStmt.close() insertStmt.close()
listStmt.close() listStmt.close()
clearStmt.close() clearStmt.close()
countAllStmt.close()
countAfterStmt.close()
if (ownsConnection) {
connection.close()
}
}
companion object {
/**
* Открывает in-memory соединение через [SQLiteConnection.memory] и
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
* runtime.
*/
fun memory(name: String? = null) =
KsqliteJournalStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
} }
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -14,13 +14,50 @@ import kotlinx.coroutines.withContext
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт * ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
* в [Schema] (миграция через PRAGMA user_version) — этот класс только * в [Schema] (миграция через PRAGMA user_version) — этот класс только
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`. * готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
*
* Каскадное удаление связанных данных (message + working_memory) делает
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
* store знает только про свою таблицу.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
* store НЕ закрывает его в [close] (используется shared-connection
* bundle'ом `SqliteStores.assemble`).
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
* в [close].
*/ */
class KsqliteMutableConversationStore( class KsqliteMutableConversationStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val messageStore: KsqliteMessageStore? = null, private val ownsConnection: Boolean,
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
) : MutableConversationStore { ) : MutableConversationStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`).
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см. // pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
@@ -125,8 +162,6 @@ class KsqliteMutableConversationStore(
// Проверяем существование через raw query, НЕ через get() — get() тоже // Проверяем существование через raw query, НЕ через get() — get() тоже
// берёт mutex (не реентрант), что привело бы к deadlock. // берёт mutex (не реентрант), что привело бы к deadlock.
if (!execExists(id)) return@withContext false if (!execExists(id)) return@withContext false
messageStore?.clear(id)
workingMemoryStore?.clear(id)
deleteStmt.reset() deleteStmt.reset()
deleteStmt.clearBindings() deleteStmt.clearBindings()
deleteStmt.bindText(1, id) deleteStmt.bindText(1, id)
@@ -188,6 +223,15 @@ class KsqliteMutableConversationStore(
renameStmt.close() renameStmt.close()
renameUpdatedAtStmt.close() renameUpdatedAtStmt.close()
touchStmt.close() touchStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteMutableConversationStore =
KsqliteMutableConversationStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
private fun execExists(id: String): Boolean { private fun execExists(id: String): Boolean {
@@ -10,10 +10,8 @@ import kotlin.time.Instant
/** /**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite. * Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
* *
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers * `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор. * не в каком-то внешнем общем модуле.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
*/ */
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) { internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload( is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
@@ -23,6 +21,7 @@ internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (r
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload( is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
content = record.content, content = record.content,
tokens = record.tokens, tokens = record.tokens,
reasoning = record.reasoning,
) )
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString( is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(), CallPayload.serializer(),
@@ -51,7 +50,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
} }
"assistant" -> { "assistant" -> {
val d = decodeBodyPayload(payload) val d = decodeBodyPayload(payload)
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens) MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning)
} }
"tool_call" -> { "tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload) val p = Json.decodeFromString(CallPayload.serializer(), payload)
@@ -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")
}
} }
@@ -2,9 +2,9 @@ package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.TurnTokens import pw.binom.agentik.content.TurnTokens
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
@@ -13,15 +13,9 @@ import kotlin.test.assertEquals
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Тесты для [KsqliteJournalStore] — точная копия * Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов * SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory * прогоняет `Schema.migrate` в init, явный вызов не нужен.
* SQLiteConnection + Schema.migrate).
*
* Тест `testClearRemovesByConversation` из оригинала использовал
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
* автономен и не знает про ConversationStore.
*/ */
class KsqliteJournalStoreTest { class KsqliteJournalStoreTest {
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}") conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteJournalStore(conn) store = KsqliteJournalStore(conn)
} }
@@ -103,4 +96,93 @@ class KsqliteJournalStoreTest {
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList()) assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size) assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
} }
@Test
fun testCountReturnsTotalForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
assertEquals(0L, store.count("conv1"))
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t, null))
store.append(MessageRecord.UserMessage("m3", "conv2", listOf(Content.Text("c")), t, null))
assertEquals(2L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("missing"))
}
@Test
fun testCountIsolatedFromOtherConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
// Bulk insert into conv1, single into conv2.
for (i in 1..50) {
store.append(MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t, null))
}
store.append(MessageRecord.UserMessage("n1", "conv2", listOf(Content.Text("only")), t, null))
assertEquals(50L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun testCountAfterFiltersStrictly() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
// Strictly > — t1 is not counted when after=t1.
assertEquals(2L, store.count("conv1", t1))
// Strictly > — t2 IS counted (only t3 after).
assertEquals(1L, store.count("conv1", t2))
// After last — empty.
assertEquals(0L, store.count("conv1", t3))
// Distant past — all three.
assertEquals(3L, store.count("conv1", Instant.DISTANT_PAST))
}
@Test
fun testCountAfterIsolatesByConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), t, null))
// Cursor that excludes everything — both conversations read 0.
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("conv1", future))
assertEquals(0L, store.count("conv2", future))
// Cursor that includes everything — only conv1's message matches its conversation.
assertEquals(1L, store.count("conv1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("conv2", Instant.DISTANT_PAST))
}
@Test
fun testCountAgreesWithListSize() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..10) {
store.append(
MessageRecord.UserMessage(
id = "m$i",
conversationId = "conv1",
content = listOf(Content.Text("x$i")),
createdAt = t0 + kotlin.time.Duration.parse("PT${i}S"),
context = null,
),
)
}
// count === list(..., 0, +∞).size
assertEquals(
store.list("conv1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("conv1"),
)
// count(after=t5) === list(..., after=t5, 0, +∞).size
val cutoff = t0 + kotlin.time.Duration.parse("PT5S")
assertEquals(
store.list("conv1", cutoff, 0, 1000).size.toLong(),
store.count("conv1", cutoff),
)
}
} }
@@ -0,0 +1,146 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Тесты для [KsqliteMutableConversationStore]. Автономная фикстура —
* `SQLiteConnection.memory(...)` + конструктор `KsqliteMutableConversationStore(connection)`.
* Store сам прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteMutableConversationStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteMutableConversationStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("conv-${kotlin.random.Random.nextLong()}")
store = KsqliteMutableConversationStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun rec(id: String, title: String? = null, ts: Instant = Instant.parse("2026-09-15T10:00:00Z")) =
ConversationRecord(id, title, false, ts, ts)
@Test
fun testUpsertAndGetRoundtrip() = runTest {
store.upsert(rec("c1", "test"))
assertEquals(rec("c1", "test"), store.get("c1"))
}
@Test
fun testGetReturnsNullForMissing() = runTest {
assertNull(store.get("nope"))
}
@Test
fun testDeleteRemovesAndReturnsTrue() = runTest {
store.upsert(rec("c1"))
assertTrue(store.delete("c1"))
assertNull(store.get("c1"))
assertEquals(false, store.delete("c1"))
}
@Test
fun testListSortsByUpdatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(rec("c1", ts = t0))
store.upsert(rec("c2", ts = t0))
store.upsert(rec("c3", ts = t0))
store.upsert(rec("c4", ts = t0))
store.touch("c2", t0 + Duration.parse("PT60S"))
store.touch("c3", t0 + Duration.parse("PT120S"))
store.touch("c4", t0 + Duration.parse("PT180S"))
val page = store.list(offset = 0, limit = 4)
assertEquals(listOf("c4", "c3", "c2", "c1"), page.map { it.id })
}
@Test
fun testListRespectsOffsetAndLimit() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..5) store.upsert(rec("c$i", ts = t0 + Duration.parse("PT${i}S")))
val p0 = store.list(offset = 0, limit = 2)
assertEquals(2, p0.size)
val p2 = store.list(offset = 4, limit = 2)
assertEquals(1, p2.size)
}
@Test
fun testRenameUpdatesTitleAndUpdatedAt() = runTest {
store.upsert(rec("c1"))
val newTs = store.rename("c1", "new title")
assertNotNull(newTs)
assertEquals("new title", store.get("c1")?.title)
}
@Test
fun testRenameWithNullClearsTitle() = runTest {
store.upsert(rec("c1", "old"))
store.rename("c1", null)
assertNull(store.get("c1")?.title)
}
@Test
fun testRenameReturnsNullForMissing() = runTest {
assertNull(store.rename("nope", "x"))
}
@Test
fun testTouchUpdatesUpdatedAtOnly() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
store.touch("c1", t1)
val got = store.get("c1")
assertEquals("title", got?.title)
assertEquals(t1, got?.updatedAt)
assertEquals(t0, got?.createdAt)
}
@Test
fun testMemoryFactoryAutoMigratesSchema() = runTest {
// Smoke-test: .memory() companion-фабрика должна прогнать Schema.migrate()
// автоматически. Если бы миграция не сработала — storePreparedStatement'ы
// упали бы на `prepare failed: no such table: conversation` ещё в конструкторе.
val owned = KsqliteMutableConversationStore.memory("conv-auto-${kotlin.random.Random.nextLong()}")
try {
owned.upsert(rec("c1", "hello"))
assertEquals("hello", owned.get("c1")?.title)
} finally {
owned.close()
}
}
@Test
fun testExternalConnectionConstructorAlsoMigrates() = runTest {
// Внешний конструктор `(connection)` ТОЖЕ мигрирует (Schema.migrate idempotent).
// Caller может не звать Schema.migrate перед конструктором.
val externalConn = SQLiteConnection.memory("conv-external-${kotlin.random.Random.nextLong()}")
val s = KsqliteMutableConversationStore(externalConn)
try {
s.upsert(rec("c1"))
assertNotNull(s.get("c1"))
} finally {
s.close()
externalConn.close()
}
}
}
@@ -1,26 +1,31 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* Тесты на Schema.migrate(): * Тесты на Schema.migrate() в `:journal-ksqlite`:
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION; * - fresh DB → создаются `conversation` + `message` + индексы
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на * (`idx_conv_updated`, `idx_msg_conv`);
* повторных CREATE); * - уже мигрированная БД → migrate() идемпотентен (no-op);
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores), * - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
* migrate() приводит её в боевое состояние. * migrate() приводит её в боевое состояние;
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
* делают full-scan по message.
* *
* Также проверяем что наличие индекса idx_msg_conv (conversation_id + * `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
* created_at) — обязательный hot-path для list()/cascade-delete. * в `pw.binom.agentik.reflection.ksqlite`.
*/ */
class SchemaMigrationTest { class SchemaMigrationTest {
@Test @Test
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest { fun `fresh DB gets conversation and message tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -28,8 +33,6 @@ class SchemaMigrationTest {
for (table in listOf( for (table in listOf(
Schema.TABLE_CONVERSATION, Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE, Schema.TABLE_MESSAGE,
Schema.TABLE_WORKING_MEMORY,
Schema.TABLE_REFLECTION,
)) { )) {
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()") assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
} }
@@ -37,15 +40,9 @@ class SchemaMigrationTest {
for (index in listOf( for (index in listOf(
Schema.IDX_CONV_UPDATED, Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_CONV, Schema.IDX_MSG_CONV,
Schema.IDX_WM_UNIQUE,
Schema.IDX_WM_CONV,
Schema.IDX_REFLECTION_CREATED,
Schema.IDX_REFLECTION_CONV,
)) { )) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()") assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
} }
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
} finally { } finally {
conn.close() conn.close()
} }
@@ -56,66 +53,50 @@ class SchemaMigrationTest {
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
val versionAfterFirst = readUserVersion(conn) // повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
// повторный вызов не должен ни упасть, ни изменить версию, ни
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn) Schema.migrate(conn)
assertEquals(versionAfterFirst, readUserVersion(conn)) Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
} finally { } finally {
conn.close() conn.close()
} }
} }
@Test @Test
fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest { fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
// Имитируем сценарий: существующая БД без schema, открываем через
// ksqlite и прогоняем migrate руками (тот же путь, что в
// KsqliteStores.open, но без зависимости от фабрики).
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn) Schema.migrate(conn)
// Сборка bundle через internal-конструктор — KsqliteStores primary val convStore = KsqliteMutableConversationStore(conn)
// constructor internal, тест в том же модуле и может его звать. val msgStore = KsqliteJournalStore(conn)
val stores = KsqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteMessageStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
try { try {
// bundle работает end-to-end — conversation upsert + message append + convStore.upsert(
// list. Никаких "no such table" или подобного. ConversationRecord(
stores.conversations.upsert(
pw.binom.agentik.journal.ConversationRecord(
id = "c1", title = "t", isTemporal = false, id = "c1", title = "t", isTemporal = false,
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
) )
) )
stores.messages.append( msgStore.append(
pw.binom.agentik.journal.MessageRecord.UserMessage( MessageRecord.UserMessage(
id = "m1", conversationId = "c1", id = "m1", conversationId = "c1",
content = listOf(pw.binom.agentik.journal.Content.Text("hi")), content = listOf(Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
) )
) )
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10) val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals("m1", got[0].id) assertEquals("m1", got[0].id)
} finally { } finally {
// Закрываем store'ы → они закроют свои pre-prepared statements convStore.close()
// (StmtHolder.finalize увидит isOpen == false и не полезет в msgStore.close()
// нативный sqlite3_finalize с уже-разрушенным db mutex).
stores.close()
} }
} }
@Test @Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest { fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
// Проверяем что индекс действительно покрывает обе колонки — без
// этого list()/cascade-delete будут делать full-scan по message.
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -158,16 +139,4 @@ class SchemaMigrationTest {
} }
return cols return cols
} }
private fun readUserVersion(conn: SQLiteConnection): Int {
var version = 0
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) {
version = (rs.getLong(0) ?: 0L).toInt()
}
}
}
return version
}
} }
+16 -7
View File
@@ -2,9 +2,8 @@
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class) // ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах. // — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
// //
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm // KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain, // `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
// доступные JVM target'у.
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) @file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins { plugins {
@@ -15,6 +14,14 @@ kotlin {
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
@@ -24,16 +31,18 @@ kotlin {
api(project(":context-api")) api(project(":context-api"))
api(project(":skills")) api(project(":skills"))
api(libs.litert.api) api(libs.litert.api)
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
// jvm/native variant из общего артефакта).
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
} implementation(libs.kotlinx.coroutines.test)
jvmMain.dependencies { implementation(libs.litert.tools.kotlinx.serialization)
// mu.KotlinLogging — JVM-only, для SkillMiner'а implementation(project(":memory-md"))
implementation(libs.kotlin.logging)
} }
} }
} }
@@ -29,7 +29,7 @@ class LlmReflector(
private val llm: LiteLlm, private val llm: LiteLlm,
val maxTurns: Int = 6, val maxTurns: Int = 6,
private val maxTokens: Int = 512, private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) { ) {
/** /**
@@ -0,0 +1,127 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools.memory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -8,7 +8,7 @@ import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.standalone.agent.FakeLiteLlm import pw.binom.agentik.llm.tools.FakeLiteLlm
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools.memory
import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision import pw.binom.agentik.memory.MemoryReviewDecision
+56
View File
@@ -0,0 +1,56 @@
# :mcp-bridge
Мост между протоколом [MCP](https://modelcontextprotocol.io/) (Model Context Protocol)
и `MutableAgent` из `:agent-api`. Превращает удалённые MCP-серверы в набор
тулов, доступных агенту через стандартный install-механизм.
## Что это и зачем
`McpRegistry` умеет подключаться к N MCP-серверам, опрашивать их
`tools/list` и держать в памяти именованные тулы (`NamedTool`). Но
`McpRegistry` сам по себе ничего не знает про агента — это просто реестр
плюс JSON-RPC-клиент.
`McpBridgeComponent` — адаптер: реализует `pw.binom.agentik.agent.Component`
и при `install(MutableAgent)` добавляет в `toolProviders` провайдер, который
возвращает текущий снимок тулов из реестра. При `uninstall` — снимает.
## Использование
```kotlin
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
val agent = ChatAgent(...)
.install(McpBridgeComponent(mcpRegistry))
```
После `install` MCP-тулы доступны агенту через стандартный
`toolsetDispatch.baseDispatcher` — вызываются точно так же, как и встроенные
(skill / memory / enable_toolset). Никаких особых путей.
На каждом tool-call `collectTools()` (внутри ChatAgent) перебирает все
`toolProviders` и собирает актуальный список — добавление/удаление компонента
видно немедленно, без рестарта агента.
## Структура
- `McpRegistry` — JSON-RPC клиент, поддерживает несколько MCP-серверов,
параллельный опрос `tools/list` при старте, reconnect.
- `McpBridgeComponent` — адаптер к `:agent-api`. Реализует `Component`;
держит ссылку на `McpRegistry`; на `install` пушит `McpToolProvider`
в `MutableAgent.toolProviders`, на `uninstall` снимает.
- `McpToolProvider` — внутренний `ToolProvider`, возвращает
`registry.namedTools`. Пустой по сути адаптер, нужен только чтобы дать
имя для удаления (removeAll-логика).
## Зависимости
- `:agent-api` — `MutableAgent`, `Component`, `ToolProvider`.
- `:agent-toolsets` — `NamedTool`, `LiteTool`.
- `:outbox-api` — для live-событий (connection-loss / reconnect notifications).
## НЕ включено
- HTTP/SSE транспорт к MCP-серверам (только stdio/JSON-RPC сейчас; HTTP-вариант
доделывается).
- Конвертация `McpResource` → `MemoryNote` (отдельная фича, не реализована).
- Авторизация OAuth (MCP 2025-06-15 draft) — пока нет.
+5 -3
View File
@@ -7,8 +7,8 @@
// или :agentik-tui когда те снова включатся. // или :agentik-tui когда те снова включатся.
// //
// Зависимости: // Зависимости:
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель) // - litert.api для LiteTool контракта (в v9 у LiteTool появилось поле name,
// - litert.api для LiteTool контракта // NamedTool-обёртка из :agent-toolsets больше не нужна)
// - MCP SDK (JVM-only) // - MCP SDK (JVM-only)
// - Ktor client (для StreamableHttpClientTransport) // - Ktor client (для StreamableHttpClientTransport)
// - kotlinx-serialization для парсинга конфига // - kotlinx-serialization для парсинга конфига
@@ -22,7 +22,9 @@ kotlin {
} }
dependencies { dependencies {
implementation(project(":agent-toolsets")) // :agent-api — отсюда Component / ToolProvider; McpBridgeComponent
// реализует Component и подсовывает MCP-тулы через ToolProvider.
api(project(":agent-api"))
api(libs.litert.api) api(libs.litert.api)
@@ -0,0 +1,61 @@
package pw.binom.agentik.mcp.bridge
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.ToolProvider
import pw.binom.litert.LiteTool
/**
* [Component], встраивающий [McpRegistry] в [MutableAgent] через [ToolProvider].
*
* При [install] добавляет один [ToolProvider] в `agent.toolProviders` —
* он возвращает `registry.allTools` (все MCP-тулы со всех подключённых
* серверов, с префиксом `serverName__` чтобы избежать коллизий).
*
* При [uninstall] убирает свой [ToolProvider] обратно. Повторный `uninstall`
* — no-op. **Не** закрывает [McpRegistry] — за это отвечает host
* (обычно shutdown hook в `Main.kt`).
*
* Типичное использование:
* ```
* val registry = McpRegistry.fromConfig(config.mcp)
* val agent = ChatAgent(...).install(McpBridgeComponent(registry))
* // ...
* Runtime.getRuntime().addShutdownHook(Thread { registry.close() })
* ```
*
* Пока встраивается только в `:standalone` через `:mcp-bridge` —
* `:agentik-cli` / `:agentik-tui` (когда снова включатся) получат эту же
* механику без изменений в [ChatAgent] constructor'е.
*/
class McpBridgeComponent(
registry: McpRegistry,
) : Component {
private val provider = McpToolProvider(registry)
override fun install(agent: MutableAgent) {
if (provider !in agent.toolProviders)
agent.toolProviders += provider
}
override fun uninstall(agent: MutableAgent) {
agent.toolProviders -= provider
}
/**
* [ToolProvider] поверх [McpRegistry.allTools]: всегда отдаёт
* полный список MCP-тулов вне зависимости от `conversationId`
* (per-conversation фильтрация для MCP будет, если/когда понадобится —
* сейчас MCP-тулы глобальны и для всех бесед одинаковы).
*/
private class McpToolProvider(
private val registry: McpRegistry,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> = registry.allTools
}
}
val McpRegistry.component
get() = McpBridgeComponent(this)
@@ -32,7 +32,6 @@ import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Реестр подключённых MCP-серверов. * Реестр подключённых MCP-серверов.
@@ -60,11 +59,6 @@ class McpRegistry(
connected.values.flatMap { it.tools } connected.values.flatMap { it.tools }
} }
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
val namedTools: List<NamedTool> by lazy {
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
}
/** Количество успешно подключённых серверов. */ /** Количество успешно подключённых серверов. */
val connectedServerCount: Int get() = connected.size val connectedServerCount: Int get() = connected.size
@@ -181,13 +175,13 @@ internal class McpLiteToolAdapter(
private val client: Client, private val client: Client,
) : LiteTool { ) : LiteTool {
internal val fullName: String = "${serverName}__${tool.name}" override val name: String = "${serverName}__${tool.name}"
override fun describe(): String = override fun describe(): String =
buildJsonObject { buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM. // Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor). // litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName) put("name", name)
put("description", tool.description ?: "") put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema()) put("parameters", tool.inputSchema.toJsonSchema())
}.toString() }.toString()
+2 -2
View File
@@ -30,9 +30,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный // ksqlite живёт в libs.versions.toml (см. catalog) — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет. // `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2") implementation(libs.ksqlite)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.io.core) implementation(libs.kotlinx.io.core)
+4 -2
View File
@@ -20,8 +20,10 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены // :proto не нужен (Event/OnlineEvent/AgentEvent/CommonEvent живут
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization). // здесь). Message-события несут общие типы содержимого из
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
api(project(":content-api"))
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core) api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json) api(libs.kotlinx.serialization.json)
@@ -2,18 +2,25 @@ package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Элемент live-потока `Conversation.events(after)`. * Элемент **durable**-потока диалога.
* *
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом * Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
* для трекинга «где остановился» при обрыве/переподключении и для разрешения * как курсор («где остановился») при обрыве/переподключении и для разрешения
* порядка при равных timestamps. * порядка при равных timestamps.
* *
* Базовая структура хода: * Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`. * вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения. * ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
* Их можно перезапросить по курсору (`after`).
*
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
* *
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`; * **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для * при миграции в `:outbox-api` был оставлен typealias в `:proto` для
@@ -27,39 +34,36 @@ sealed interface Event {
/** Момент эмиссии события в UTC. */ /** Момент эмиссии события в UTC. */
val date: Instant val date: Instant
/**
* Целое пользовательское сообщение хода. Эмитится в тот же момент, когда
* запись попадает в журнал (для персистентных диалогов). [id] совпадает
* с id соответствующего `MessageRecord.UserMessage`/`Message.UserMessage`.
*/
@Serializable @Serializable
enum class ResponseType { @SerialName("user_message")
@SerialName("text") TEXT, data class UserMessage(
@SerialName("image") IMAGE override val date: Instant,
} val id: String,
val content: List<Content>,
val context: MessageContext? = null,
) : Event
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */ /**
* Целое сообщение ассистента — итог хода. Эмитится при завершении хода,
* после того как текст ответа полностью собран.
*
* [reasoning] — текст размышлений модели (chain-of-thought), если провайдер
* его отдаёт; иначе `null`. [tokens] — расход токенов за ход.
*/
@Serializable @Serializable
@SerialName("start_reasoning") @SerialName("assistant_message")
data class StartReasoning(override val date: Instant) : Event data class AssistantMessage(
override val date: Instant,
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */ val id: String,
@Serializable val content: List<Content>,
@SerialName("start_response") val reasoning: String? = null,
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event val tokens: TurnTokens? = null,
) : Event
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
@Serializable
@SerialName("end")
data class End(override val date: Instant) : Event
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
@Serializable
@SerialName("append_text")
data class AppendText(override val date: Instant, val body: String) : Event
@Serializable
@SerialName("append_image")
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
/** /**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет. * Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
@@ -99,6 +103,14 @@ sealed interface Event {
val result: String?, val result: String?,
) : Event ) : Event
/**
* Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется
* в истории.
*/
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
/** /**
* Ошибка хода. После неё поток завершается; дальнейшие события могут * Ошибка хода. После неё поток завершается; дальнейшие события могут
* прийти, но ход считается проваленным. * прийти, но ход считается проваленным.
@@ -106,4 +118,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
} }
@@ -0,0 +1,26 @@
package pw.binom.agentik.outbox
/**
* Write-сторона [OnlineOutbox]: используется продюсерами
* ([pw.binom.agentik.standalone.agent.ConversationLoop] и т.п.) для эмиссии
* live-дельт. Наружу (в [pw.binom.agentik.proto.Agent]) отдаётся
* read-only [OnlineOutbox].
*
* **Non-suspend и best-effort**: онлайн-события по определению нигде не
* персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline]
* не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может
* потерять дельту, это допустимо.
*/
interface MutableOnlineOutbox : OnlineOutbox {
suspend fun appendOnline(conversationId: String, event: OnlineEvent)
/**
* Эмитит [event] в live-канал диалога [conversationId]. Не сохраняется.
*
* Возвращает `true`, если событие принято live-каналом. Возврат `false`
* (нет активных подписчиков / буфер переполнен с DROP-политикой) —
* не ошибка: у онлайн-событий нет гарантии доставки.
*/
fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean
}
@@ -0,0 +1,70 @@
package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
* стриминг ответа агента (дельты текста/картинок).
*
* Принципиальное отличие от [Event] (durable):
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
* ни в journal. Это чистый live-канал.
* - **Только онлайн-подписка**: события, эмитнутые до подписки
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
* Потерянный фрагмент не страшен — целый результат хода приходит
* durable-событием ([Event.AssistantMessage]) и/или лежит в журнале.
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
* есть что реплеить.
*
* Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
* который, попав в durable store, копится в RAM (standalone-outbox растёт
* unbounded) и засоряет историю. В [Event] остаются только «целые» события,
* пригодные к перезапросу по курсору.
*/
@Serializable
sealed interface OnlineEvent {
/** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */
val date: Instant
@Serializable
enum class ResponseType {
@SerialName("text") TEXT,
@SerialName("image") IMAGE
}
/**
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
* [End]/[Event.Interrupted]/[Event.Error], синхронно из `Conversation.send()`,
* чтобы UI мог показать спиннер ещё до первого токена ответа.
*/
@Serializable
@SerialName("working")
data class Working(override val date: Instant) : OnlineEvent
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [Event]. */
@Serializable
@SerialName("end")
data class End(override val date: Instant) : OnlineEvent
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
@Serializable
@SerialName("start_reasoning")
data class StartReasoning(override val date: Instant) : OnlineEvent
/** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */
@Serializable
@SerialName("start_response")
data class StartResponse(override val date: Instant, val responseType: ResponseType) : OnlineEvent
/** Очередная дельта текста ответа. */
@Serializable
@SerialName("append_text")
data class AppendText(override val date: Instant, val body: String) : OnlineEvent
/** Очередная дельта картинки ответа. */
@Serializable
@SerialName("append_image")
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : OnlineEvent
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.outbox
import kotlinx.coroutines.flow.Flow
/**
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
* агента «в моменте».
*
* Контракт принципиально проще [OutboxStore]:
* - **без catchup**: `onlineEvents` отдаёт только то, что эмитится *после*
* подписки. Прошлое не реплеится — его и нет (нечего хранить).
* - **без курсора**: нет `after`/`lastSeen`.
* - **без TTL/buffer**: [OnlineEvent] не буферизуются.
*
* Это осознанный компромисс: дельты токенов — высокочастотный мусор,
* который в durable-сторе копился бы в RAM и засорял историю. Потеря
* фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage]
* и/или лежит в [pw.binom.agentik.journal.JournalStore].
*
* Read-only view: запись — через [MutableOnlineOutbox].
*/
interface OnlineOutbox : AutoCloseable {
fun onlineEvents(): Flow<OnlineEvent>
/**
* Подписка на live-поток онлайн-событий диалога [conversationId].
* События, эмитнутые до подписки, не приходят.
*/
fun onlineEvents(conversationId: String): Flow<OnlineEvent>
/** Освобождает ресурсы. Idempotent. */
override fun close()
}
@@ -8,6 +8,11 @@ import kotlin.time.Instant
/** /**
* Bounded-tail event log с автоматическим управлением TTL. * Bounded-tail event log с автоматическим управлением TTL.
* *
* Хранит **только durable-события [Event]** — «целые» факты хода
* (Working/End/Interrupted/Error, ToolCall/ToolResult/ToolFailed).
* Высокочастотный **стриминг ответа** (дельты текста/картинок) сюда
* НЕ попадает — он живёт в [OnlineOutbox] (live-only, не сохраняется).
*
* **Архитектура двухуровневого хранилища событий**: * **Архитектура двухуровневого хранилища событий**:
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay). * 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
* События автоматически эвиктятся по TTL/cap (implementation-defined). * События автоматически эвиктятся по TTL/cap (implementation-defined).
@@ -0,0 +1,69 @@
package pw.binom.agentik.outbox.inmemory
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.outbox.OnlineEvent
/**
* In-memory реализация [MutableOnlineOutbox] — единственный live-канал
* без хранения.
*
* **Никакого буфера событий**: [OnlineEvent] нигде не накапливаются
* (в этом весь смысл — дельты токенов не должны течь в durable-стор).
* Единственное, что живёт в памяти, — [MutableSharedFlow] с BOUNDED
* internal buffer'ом для развязки продюсера/подписчиков; при переполнении
* **старые дропаются** ([BufferOverflow.DROP_OLDEST]), [appendOnline] не
* блокируется. Потеря дельты допустима (см. [OnlineOutbox]).
*
* **Маршрутизация**: один общий [MutableSharedFlow] c `conversationId`
* в envelope; [onlineEvents] фильтрует по диалогу. Отдельный flow-на-диалог
* не держим, чтобы не плодить per-conversation подписки, которые надо
* чистить вручную.
*/
class InMemoryOnlineOutbox(
liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY,
) : MutableOnlineOutbox {
private data class Envelope(val conversationId: String, val event: OnlineEvent)
private val liveFlow = MutableSharedFlow<Envelope>(
replay = 0,
extraBufferCapacity = liveBufferCapacity,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
require(liveBufferCapacity > 0) {
"liveBufferCapacity must be > 0, got $liveBufferCapacity"
}
}
override fun onlineEvents(): Flow<OnlineEvent> =
liveFlow.map { it.event }
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
liveFlow.filter { it.conversationId == conversationId }.map { it.event }
override suspend fun appendOnline(conversationId: String, event: OnlineEvent)=
liveFlow.emit(Envelope(conversationId, event))
override fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean =
liveFlow.tryEmit(Envelope(conversationId, event))
override fun close() {
// replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки.
// Идемпотентно: повторный close() безопасен.
}
private companion object {
// Достаточно, чтобы не терять подряд идущие маркеры фаз
// (Working/StartReasoning/StartResponse) и первые дельты, пока
// подписчик не возобновил сбор. Дельты токенов при переполнении
// всё равно дропаются (DROP_OLDEST) — потеря фрагмента допустима.
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 64
}
}
@@ -164,7 +164,7 @@ class InMemoryOutboxStoreTest {
store.append(CommonEvent.Conversation( store.append(CommonEvent.Conversation(
date = now, date = now,
conversationId = "c-1", conversationId = "c-1",
event = Event.AppendText(date = now, body = "hi"), event = Event.Interrupted(date = now),
)) ))
// Snapshot-based test of the default impl (uses events() + filterIsInstance). // Snapshot-based test of the default impl (uses events() + filterIsInstance).
@@ -180,9 +180,9 @@ class InMemoryOutboxStoreTest {
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking { fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0) val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "a"))) store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
store.append(CommonEvent.Conversation(now, "c-2", Event.AppendText(now, "b"))) store.append(CommonEvent.Conversation(now, "c-2", Event.Interrupted(now)))
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "c"))) store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
// Test the filter logic by manually filtering snapshot. // Test the filter logic by manually filtering snapshot.
val c1 = store.snapshot() val c1 = store.snapshot()
@@ -200,7 +200,7 @@ class InMemoryOutboxStoreTest {
date = now, date = now,
event = AgentEvent.Created(date = now, conversationId = "created"), event = AgentEvent.Created(date = now, conversationId = "created"),
)) ))
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "hi"))) store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
val all = store.snapshot() val all = store.snapshot()
val agents = all.filterIsInstance<CommonEvent.Agent>() val agents = all.filterIsInstance<CommonEvent.Agent>()
+56 -26
View File
@@ -59,57 +59,87 @@ target-specific артефакты + общий `kotlinMultiplatform`.
## Основные типы ## Основные типы
`:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`,
а общие типы содержимого и события живут в нижележащих модулях:
`Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`,
`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
```kotlin ```kotlin
interface Agent { interface Agent : AutoCloseable {
fun id: String val id: String
suspend fun createConversation(title: String? = null): Conversation val info: AgentInfo
val journal: JournalStore // append-only audit (read-only)
val outbox: OutboxStore // durable-события: catchup+live по курсору
val onlineOutbox: OnlineOutbox // live-only: стриминг, без курсора
val conversationStore: ConversationStore
fun createConversation(temp: Boolean): Conversation
suspend fun getConversation(id: String): Conversation? suspend fun getConversation(id: String): Conversation?
suspend fun getConversations(offset: Int = 0): Flow<Conversation> suspend fun deleteConversation(id: String): Boolean
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed suspend fun renameConversation(id: String, title: String?): Instant?
} }
interface Conversation : AutoCloseable { interface Conversation : AutoCloseable {
val id: String val id: String
val updatedAt: Instant val updatedAt: Instant
val isTemporal: Boolean
val title: String?
val isSupportImageInput: Boolean val isSupportImageInput: Boolean
val isSupportImageOutput: Boolean val isSupportImageOutput: Boolean
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше suspend fun send(content: List<Content>, context: MessageContext? = null) // fire-and-forget
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
suspend fun getMessages(offset: Int = 0): Flow<Message> suspend fun rename(title: String)
suspend fun rename(title: String): Boolean suspend fun interrupt()
fun interrupt()
} }
// :content-api
sealed interface Content { sealed interface Content {
class Text(val body: String) : Content data class Text(val body: String) : Content
class Image(val data: ByteArray, val mime: String) : Content data class Image(val data: ByteArray, val mime: String) : Content
} }
enum class MessageOrigin { USER, SYSTEM, EVENT }
data class MessageContext(origin: MessageOrigin, description: String?, sourceId: String?, metadata: JsonElement?)
// :proto
sealed interface Message { sealed interface Message {
val id: String val id: String
val date: Instant val date: Instant
interface Body : Message { val content: List<Content> } class UserMessage(id, content: List<Content>, date, context: MessageContext?) : Message
interface System : Message class AssistantMessage(id, content: List<Content>, date, tokens: TurnTokens?, reasoning: String?) : Message
class UserMessage(...) : Body class ToolCall(...) : Message
class AssistantMessage(...) : Body class ToolResult(...) : Message
class ToolCall(...) : System class Error(...) : Message
class ToolResult(...) : System
} }
// :outbox-api — durable (перезапрашиваются по курсору `after`)
sealed interface Event { sealed interface Event {
enum ResponseType { TEXT, IMAGE } val date: Instant
class StartReasoning(...) : Event class UserMessage(date, id, content: List<Content>, context: MessageContext?) : Event
class StartResponse(val type: ResponseType) : Event class AssistantMessage(date, id, content: List<Content>, reasoning: String?, tokens: TurnTokens?) : Event
class AppendText(val body: String) : Event
class AppendImage(val body: ByteArray, val mime: String) : Event
class End(...) : Event
class Interrupted(...) : Event
class Error(val message: String, val code: Int? = null) : Event
class ToolCall(...) : Event class ToolCall(...) : Event
class ToolResult(...) : Event class ToolResult(...) : Event
class ToolFailed(...) : Event
class Interrupted(date) : Event
class Error(date, message, code) : Event
class ConversationClosing(...) : Event
class CompactionTriggered(...) : Event
}
// :outbox-api — online (live-only, НИКОГДА не сохраняются)
sealed interface OnlineEvent {
val date: Instant
enum ResponseType { TEXT, IMAGE }
class Working(date) : OnlineEvent
class End(date) : OnlineEvent
class StartReasoning(date) : OnlineEvent
class StartResponse(date, responseType: ResponseType) : OnlineEvent
class AppendText(date, body: String) : OnlineEvent
class AppendImage(date, body: ByteArray, mime: String) : OnlineEvent
} }
``` ```
Ход в терминах маркеров: онлайн `Working` → … → онлайн `End`; durable —
`UserMessage` в начале и `AssistantMessage`/`Interrupted`/`Error` в конце.
## Чего здесь НЕТ ## Чего здесь НЕТ
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server` - Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
+4 -3
View File
@@ -28,11 +28,12 @@ kotlin {
// public-сигнатуре Agent, поэтому api-висимости. // public-сигнатуре Agent, поэтому api-висимости.
// //
// Линейный граф зависимостей (без циклов): // Линейный граф зависимостей (без циклов):
// :proto ──► :content-api (Content/MessageContext/MessageOrigin/TurnTokens)
// :proto ──► :outbox-api (нет обратной зависимости) // :proto ──► :outbox-api (нет обратной зависимости)
// :proto ──► :journal-api (нет обратной зависимости) // :proto ──► :journal-api (нет обратной зависимости)
// Добились переносом AgentEvent/CommonEvent/Event из :proto в // Общие типы содержимого живут в низкоуровневом :content-api,
// :outbox-api — они теперь self-contained в outbox (не нужны // чтобы ими пользовались proto/journal/outbox без дублей и циклов.
// :proto-типы), а :proto использует их через :outbox-api. api(project(":content-api"))
api(project(":journal-api")) api(project(":journal-api"))
api(project(":outbox-api")) api(project(":outbox-api"))
} }
@@ -2,6 +2,8 @@ package pw.binom.agentik.proto
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import kotlin.time.Instant import kotlin.time.Instant
@@ -12,9 +14,10 @@ import kotlin.time.Instant
* [createConversation] возвращает [Conversation], который сам хранит историю * [createConversation] возвращает [Conversation], который сам хранит историю
* и которому отправляют ходы через [Conversation.send]. * и которому отправляют ходы через [Conversation.send].
* *
* **Хранилища вынесены в [Agent.journal], [Agent.outbox] и * **Хранилища вынесены в [Agent.journal], [Agent.outbox],
* [Agent.conversationStore]**: все три read-only views. События живут * [Agent.onlineOutbox] и [Agent.conversationStore]**: read-only views.
* в [outbox] как `OutboxStore.events(after)` / `outbox.agentEvents(after)`. * Durable-события живут в [outbox] как `OutboxStore.events(after)` /
* `outbox.agentEvents(after)`; live-стриминг ответа — в [onlineOutbox].
* Это даёт единый путь для всех read-операций по хранилищу и убирает * Это даёт единый путь для всех read-операций по хранилищу и убирает
* дублирование между протоколом и хранилищем. * дублирование между протоколом и хранилищем.
* *
@@ -28,6 +31,26 @@ interface Agent : AutoCloseable {
/** Идентификатор агента. */ /** Идентификатор агента. */
val id: String val id: String
/**
* Публичное представление имени/назначения агента — то, что агент
* сам "рассказывает о себе" клиентам и UI.
*
* Контрактно отличается от [id]:
* - [id] — opaque stable identifier (логи, корреляция, A2A contextId);
* - [info.name] — **человекочитаемое** имя, которое пользователь
* может сменить через конфиг или UI; может меняться в течение жизни
* агента.
*
* Используется HTTP-фасадом `:server` (endpoint под `path` самого
* агента) и A2A-фасадом (для заполнения AgentCard `name` / `description`).
*
* Доступ к [info] всегда дешёвый — это snapshot `data class` без побочных
* эффектов. Конкретные реализации ([MutableAgent]) могут выставлять
* `info` конструкторным параметром; [Agent] контрактно не требует
* реактивности — клиенты получают снимок в момент обращения.
*/
val info: AgentInfo
/** /**
* Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки). * Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки).
* После [close] вызовы [createConversation] / [getConversation] и т.п. * После [close] вызовы [createConversation] / [getConversation] и т.п.
@@ -63,6 +86,24 @@ interface Agent : AutoCloseable {
*/ */
val outbox: OutboxStore val outbox: OutboxStore
/**
* Live-канал **онлайн-событий** ([OnlineEvent] — стриминг ответа агента).
*
* Отделён от [outbox] принципиально:
* - [outbox] — durable: «целые» события, перезапрашиваемые по курсору
* (`after`), с catchup + live;
* - [onlineOutbox] — live-only: дельты токенов, **никогда не сохраняются**,
* без catchup/курсора, подписка работает только «онлайн».
*
* Используется HTTP-фасадом `:server` для endpoint'а
* `GET /{path}/conversations/{id}/online` (SSE). Клиент рендерит ответ
* из этого потока в реальном времени, а durable-историю берёт из [journal].
*
* **Read-only**: write-доступ только через `MutableOnlineOutbox` внутри
* ChatAgent / ConversationLoop, не через [Agent] interface.
*/
val onlineOutbox: OnlineOutbox
/** /**
* Read-only view на `conversation` table (id + title + timestamps). * Read-only view на `conversation` table (id + title + timestamps).
* *
@@ -0,0 +1,34 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Идентификационная информация об [Agent] — публичное представление для
* UI/A2A-агентов/IRC/визуальных дашбордов: имя ("как зовут ассистента"),
* краткое описание ("что он делает") и зачем он может быть полезен
* ("в каких задачах помогает").
*
* Контрактное отличие от [Agent.id]:
* - [Agent.id] — стабильный opaque identifier для корреляции, логов и
* транспорта (A2A `contextId`, IRC CTCP-запросы, etc). Менять нельзя.
* - [AgentInfo.name] — **человекочитаемое имя**, которое показывается
* клиенту и которое пользователь может настроить (например,
* `AGENTIK_NAME` в standalone-конфиге). Может меняться.
*
* Wire-формат (через `:server` JSON-фасад): `name` — обязательное поле,
* `description` и `usefulness` — опциональные, могут отсутствовать.
*
* @property name Человекочитаемое имя ассистента. Обязательное.
* @property description Краткое описание (что агент делает/кто он).
* Не сериализуется, если `null`.
* @property usefulness Зачем агент может быть полезен. Не сериализуется,
* если `null`.
*/
@Serializable
data class AgentInfo(
val name: String,
val description: String? = null,
@SerialName("usefulness")
val usefulness: String? = null,
)
@@ -1,18 +0,0 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
class Text(val body: String) : Content
/**
* Картинка. [mime] — MIME-тип, например `"image/png"`, `"image/jpeg"`.
*/
@Serializable
@SerialName("image")
class Image(val data: ByteArray, val mime: String) : Content
}
@@ -2,6 +2,8 @@ package pw.binom.agentik.proto
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -9,15 +11,21 @@ import kotlin.time.Instant
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри * [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
* [Conversation]. * [Conversation].
* *
* **Live-события** диалога (turn stream: StartReasoning / AppendText / End / * **Live-события** диалога НЕ часть этого интерфейса. Их два независимых
* ToolCall / ToolResult / ...) НЕ часть этого интерфейса — единственный * потока:
* источник live-событий это [pw.binom.agentik.outbox.OutboxStore]. * - **durable** ([pw.binom.agentik.outbox.Event]: UserMessage / AssistantMessage /
* Подписаться на события конкретного диалога: * Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из
* [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору:
* ``` * ```
* agent.outbox.conversationEvents(after = lastSeen, conversationId = id) * agent.outbox.conversationEvents(after = lastSeen, conversationId = id)
* .map { it.event } * .map { it.event }
* .collect { e -> ... } * .collect { e -> ... }
* ``` * ```
* - **online** ([pw.binom.agentik.outbox.OnlineEvent]: Working / End /
* StartReasoning / StartResponse / AppendText / AppendImage) — live-only
* стриминг ответа из [pw.binom.agentik.outbox.OnlineOutbox], без
* catchup/курсора: `agent.onlineOutbox.onlineEvents(id)`.
*
* Для cross-conversation view (admin / parent-agent / debug): * Для cross-conversation view (admin / parent-agent / debug):
* `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed): * `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed):
* `agent.outbox.agentEvents(after)`. * `agent.outbox.agentEvents(after)`.
@@ -2,6 +2,9 @@ package pw.binom.agentik.proto
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
import kotlin.time.Instant import kotlin.time.Instant
@Serializable @Serializable
@@ -33,7 +36,17 @@ sealed interface Message {
@Serializable @Serializable
@SerialName("assistant_message") @SerialName("assistant_message")
class AssistantMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message class AssistantMessage(
override val id: String,
val content: List<Content>,
override val date: Instant,
val tokens: TurnTokens? = null,
/**
* Текст размышлений модели (chain-of-thought / reasoning), если
* провайдер его отдаёт. Опционально.
*/
val reasoning: String? = null,
) : Message
@Serializable @Serializable
@SerialName("tool_call") @SerialName("tool_call")
@@ -1,94 +0,0 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Кто/что инициировал данный ход сообщения.
*
* Используется в [MessageContext] — каждый ход диалога может нести
* дополнительный контекст о природе триггера:
* - [USER] — обычное сообщение от пользователя в чате (дефолт, context=null).
* - [SYSTEM] — программное системное сообщение (старт агента, режим обслуживания,
* уведомление о завершении фоновой задачи).
* - [EVENT] — внешнее событие (cron, webhook, file-changed, и т.п.).
* В этом случае [MessageContext.sourceId] и [MessageContext.description]
* позволяют модели понять, что за источник её разбудил.
*
* Семантический контракт:
* - origin != USER ⇒ [MessageContext.description] обязателен и должен быть
* человекочитаемым (короткая фраза для модели).
* - origin == USER ⇒ context может быть `null` (дефолт), и если задан — поля
* интерпретируются как «дополнительная мета» (например, ui_client).
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
/**
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
*
* Примеры:
* ```
* // cron-задача утренней сводки
* MessageContext(
* origin = MessageOrigin.EVENT,
* description = "scheduled cron 'morning-briefing'",
* sourceId = "cron-42",
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
* )
*
* // обычное сообщение из IRC
* MessageContext(
* origin = MessageOrigin.USER,
* sourceId = "irc-channel:agentik",
* description = "PRIVMSG from nick",
* )
*
* // старт агента после рестарта
* MessageContext(
* origin = MessageOrigin.SYSTEM,
* description = "agent startup greeting",
* )
* ```
*
* Сериализация: snake_case для стабильного wire-формата ([origin] идёт как
* `user`/`system`/`event` благодаря @SerialName на enum).
*
* Forward-совместимо: добавление новых полей — non-breaking для старых
* клиентов, которые их игнорируют.
*/
@Serializable
data class MessageContext(
val origin: MessageOrigin,
/**
* Короткая человекочитаемая фраза для LLM: попадает в working memory
* как префикс `[origin] description (sourceId=…)` к user-сообщению,
* чтобы модель видела, что её разбудил не пользователь, а событие.
*/
val description: String? = null,
/**
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC,
* id фонового события. Помогает модели и оператору при логировании понять,
* откуда пришёл ход.
*/
val sourceId: String? = null,
/**
* Произвольный структурированный payload о событии.
* Например: `{"scheduledAt": "...", "rule": "..."}` для cron,
* или `{"headers": {...}, "ip": "..."}` для webhook.
*
* Никогда не попадает в LLM-нагрузку как сырой JSON — используется
* только для логирования и пост-аналитики.
*/
val metadata: JsonElement? = null,
)

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