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