Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68543357c2 | |||
| f946186ef5 | |||
| fb963bfb6b | |||
| 818f022ba3 | |||
| 0d1be42919 | |||
| 2d6cf89c52 | |||
| 8f85612665 | |||
| aef5083801 | |||
| 499db812ef | |||
| d6afc05c20 | |||
| acc7237e51 | |||
| bd65c29b48 | |||
| 2cc1d923b3 | |||
| 8f8f7f1020 | |||
| 3f260f3ac8 | |||
| d74af621d8 | |||
| 15f3952eba | |||
| a0b1209457 | |||
| a05e260457 | |||
| 7e66baf9e3 | |||
| 1134e32ea2 | |||
| e2f0e434d1 | |||
| 1f85cde1b8 | |||
| 8bb24dab3c | |||
| 7358175499 | |||
| 2d9ad526bb | |||
| dd7aec8df1 | |||
| bf2649a856 | |||
| 4ad59d5f5d | |||
| c140d0b758 | |||
| c486c7f9ab | |||
| 9d310c5fd0 | |||
| 741ad8963d | |||
| 2634e0e204 | |||
| ac5d209fce | |||
| 25771a0c33 | |||
| 78cbe9b463 |
+14
-9
@@ -17,6 +17,14 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# UTF-8 обязателен: в именах тестов есть типографские символы (—), а Kotlin-компилятор
|
||||
# создаёт .class-файлы с именем теста. При LANG=C sun.jnu.encoding = ASCII, и компилятор
|
||||
# падает с "InvalidPathException: Malformed input or input contains unmappable characters"
|
||||
# (проверено локально: LANG=C → BUILD FAILED, LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||
env:
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
jobs:
|
||||
build-jvm:
|
||||
name: JVM build + tests
|
||||
@@ -68,12 +76,9 @@ jobs:
|
||||
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
||||
|
||||
- name: Upload shadowJars
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agentik-jars
|
||||
path: |
|
||||
standalone/build/libs/standalone-*-all.jar
|
||||
agentik-cli/build/libs/agentik-cli-*-all.jar
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||
# и валит весь джоб уже ПОСЛЕ успешной сборки и зелёных тестов.
|
||||
# У соседних репо (asr-kmp, litert-kmp) артефакты наружу тоже не выгружаются —
|
||||
# проверка сборки ограничивается test -f на jar (шаги выше).
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
|
||||
# отдельно от этого workflow.
|
||||
#
|
||||
# Требуемые Gitea Action Variables:
|
||||
# BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/
|
||||
# Требуемые Gitea Action Secrets:
|
||||
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
|
||||
# Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
|
||||
# публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
|
||||
# версия берётся из тега каждый раз.
|
||||
#
|
||||
# Публикация выполняется общим composite-action'ом subochev/devops/publish@main
|
||||
# (тот же, что у asr-kmp / litert-kmp / embedder-kmp / a2a-protocol) — credentials
|
||||
# BINOM_REPO_* берутся им из Gitea Action Variables (owner_id=0, глобальные).
|
||||
name: release
|
||||
|
||||
on:
|
||||
@@ -22,6 +25,15 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# UTF-8 обязателен: генерация POM/Kotlin-метаданных и имена тестовых классов
|
||||
# содержат не-ASCII символы; при LANG=C sun.jnu.encoding = ASCII и сборка
|
||||
# падает с "InvalidPathException: Malformed input or input contains unmappable
|
||||
# characters" (проверено локально 19.09.2026: LANG=C → BUILD FAILED,
|
||||
# LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||
env:
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
jobs:
|
||||
publish-libraries:
|
||||
name: Publish KMP libraries → caffeine Nexus
|
||||
@@ -31,46 +43,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
- name: Publish libraries (all KMP targets, all modules) to Nexus
|
||||
uses: https://git.binom.pw/subochev/devops/publish@main
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-agentik-
|
||||
|
||||
- name: Publish libraries (all KMP targets, all modules)
|
||||
shell: bash
|
||||
env:
|
||||
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
|
||||
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
|
||||
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
|
||||
run: |
|
||||
# Gitea Actions (Forgejo-based) экспонирует env-переменные под
|
||||
# GITHUB_-префиксом: GITHUB_REF_NAME = "v0.1.0" для tag-trigger'а.
|
||||
# Внутри bash подставляем через $GITHUB_REF_NAME (а не
|
||||
# ${GITEA_REF_NAME} — Forgejo этого не подставляет).
|
||||
#
|
||||
# Версия = имя тега (с trim'ом опционального префикса 'v'), чтобы
|
||||
# тег "1" публиковался как pw.binom.agentik:<module>:1. CICD не
|
||||
# хардкодит версию — берёт её из тега каждый раз.
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
VERSION="${TAG#v}"
|
||||
echo "Publishing version: ${VERSION}"
|
||||
./gradlew \
|
||||
"-Pversion=${VERSION}" \
|
||||
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
|
||||
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
|
||||
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
|
||||
publish \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
version: ${{ gitea.ref_name }}
|
||||
|
||||
@@ -20,6 +20,9 @@ out/
|
||||
.cortexkit/
|
||||
.veai/
|
||||
|
||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||
.tasks/
|
||||
|
||||
# Runtime / test artifacts
|
||||
agentik.db
|
||||
agentik.db-shm
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
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.
|
||||
@@ -77,7 +77,7 @@ budget exhaustion, registry filter, parallel dispatch.
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
||||
- Никакого persistent storage. Опирается на контракт `ContextStore`
|
||||
(см. `:storage-core`).
|
||||
|
||||
## Текущий статус
|
||||
|
||||
@@ -21,8 +21,9 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
|
||||
api(project(":storage-core"))
|
||||
api(project(":journal-api"))
|
||||
api(project(":reflection-api"))
|
||||
api(project(":context-api"))
|
||||
|
||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||
api(libs.litert.api)
|
||||
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
@@ -8,5 +8,8 @@ import pw.binom.litert.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)
|
||||
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
|
||||
* Контекст, который тулсеты получают при активации.
|
||||
*
|
||||
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
||||
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||
* добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||
* могли читать/писать сообщения и память.
|
||||
*
|
||||
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
||||
|
||||
@@ -41,6 +41,7 @@ kotlin {
|
||||
implementation(libs.kotlinx.cli)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
||||
// весь код, включая platformEnv, лежит в commonMain.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package pw.binom.agentik.cli
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import pw.binom.agentik.client.applyAgentikDefaults
|
||||
|
||||
/**
|
||||
* HTTP-клиент CLI: движок CIO + конфигурация agentik.
|
||||
*
|
||||
* Движок выбирается здесь, а не в `:client`: библиотека не выбирает транспорт за
|
||||
* потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64)
|
||||
* покрываются CIO.
|
||||
*
|
||||
* `requestTimeout = 0` — отключение встроенного request-таймаута CIO;
|
||||
* defense-in-depth против обрыва долгих SSE-idle (основная защита —
|
||||
* `noSseReadTimeout` в `:client`).
|
||||
*
|
||||
* [token] = `null` — авторизация выключена.
|
||||
*/
|
||||
internal fun defaultCliHttpClient(token: String? = null): HttpClient =
|
||||
HttpClient(CIO) {
|
||||
applyAgentikDefaults(token)
|
||||
engine { requestTimeout = 0 }
|
||||
}
|
||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val ok = agent.deleteConversation(id)
|
||||
if (ok) println("deleted: $id") else println("conversation not found: $id")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
|
||||
import kotlinx.cli.ArgType
|
||||
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.Agent
|
||||
|
||||
@@ -10,7 +11,7 @@ class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов а
|
||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
||||
if (convs.isEmpty()) {
|
||||
println("(no conversations)")
|
||||
|
||||
+2
-1
@@ -3,13 +3,14 @@ package pw.binom.agentik.cli.commands
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
||||
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val conv = agent.createConversation(temp = temp)
|
||||
println(conv.id)
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
||||
@@ -9,7 +10,7 @@ class ConvRenameSubcommand : ConvSubcommand("rename", "Переименоват
|
||||
val title by argument(ArgType.String, description = "Новое название")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
|
||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
|
||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
|
||||
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
|
||||
import kotlinx.cli.ArgType
|
||||
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.proto.Message
|
||||
@@ -13,7 +14,7 @@ class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообще
|
||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
|
||||
@@ -7,9 +7,10 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.launch
|
||||
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.proto.Event
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||
@@ -17,7 +18,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
||||
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
|
||||
@@ -46,7 +46,11 @@ internal class TuiBackend(
|
||||
state.postSystem("подключено к ${state.config.server}")
|
||||
scope.launch {
|
||||
try {
|
||||
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
||||
// распаковываем .event для получения AgentEvent (раньше был
|
||||
// отдельный метод agent.events(), теперь упразднён — события
|
||||
// живут в outbox-сущности).
|
||||
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||
// штатная отмена при закрытии UI
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -3,8 +3,9 @@ package pw.binom.agentik.tui
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
@@ -25,6 +26,16 @@ internal class FakeAgent(
|
||||
private set
|
||||
val conversations = mutableListOf<FakeConversation>()
|
||||
|
||||
// Storage handles не используются тестами TuiBackend — тесты проверяют
|
||||
// маршрутизацию Conversation.events в UI state. Outbox stub-ы возвращают
|
||||
// emptyFlow, journal — error-on-access (никто не должен его трогать).
|
||||
override val journal: JournalStore = error("journal not used in TuiBackend tests")
|
||||
override val outbox: OutboxStore = object : OutboxStore {
|
||||
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.proto.CommonEvent>()
|
||||
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation {
|
||||
createCount++
|
||||
val c = conversationFactory()
|
||||
@@ -40,8 +51,6 @@ internal class FakeAgent(
|
||||
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
||||
conversations.toList()
|
||||
|
||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -205,7 +205,7 @@ class TuiBackendTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onUserMessage does not swallow exceptions — state stays consistent`() = runTest {
|
||||
fun `onUserMessage does not swallow exceptions - state stays consistent`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
|
||||
|
||||
+296
-66
@@ -1,101 +1,331 @@
|
||||
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
|
||||
# `:client` — Ktor-клиент к `:server` (KMP, jvm + native)
|
||||
|
||||
## Что это
|
||||
Тонкий HTTP-клиент к `:server`-фасаду + локальные примитивы, чтобы
|
||||
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
|
||||
про HTTP, JSON, SSE и lifecycle `Conversation`.
|
||||
|
||||
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
|
||||
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
|
||||
интерфейсы `:proto`:
|
||||
## Что есть
|
||||
|
||||
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
|
||||
- `AgentClient` — список и lifecycle диалогов.
|
||||
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
||||
`getMessages()`, `rename()`, `close()`.
|
||||
- Внутренний парсер SSE → `Flow<Event>`.
|
||||
- `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
|
||||
`Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
|
||||
из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
|
||||
- `Agent`: `createConversation` / `getConversation` / `getConversations` /
|
||||
`deleteConversation` / `journal` / `outbox` / `close`.
|
||||
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
|
||||
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
|
||||
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
|
||||
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
|
||||
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
|
||||
|
||||
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
|
||||
с теми же типами, что и сервер. Один и тот же клиент работает на
|
||||
JVM, iOS, macOS, Linux, Windows.
|
||||
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:agentik-cli` — REPL.
|
||||
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
||||
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
||||
|
||||
## Как подключить
|
||||
## Подключение
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
dependencies {
|
||||
api("pw.binom.agentik:client:0.1.0")
|
||||
}
|
||||
}
|
||||
|
||||
// ваш код:
|
||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
||||
val conv = agent.createConversation(title = "test")
|
||||
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
||||
when (event) {
|
||||
is Event.AppendText -> print(event.body)
|
||||
is Event.End -> println("\n--- end ---")
|
||||
is Event.Error -> error("agent error: ${event.message}")
|
||||
else -> Unit
|
||||
}
|
||||
// Движок — на твой выбор (один из):
|
||||
implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native
|
||||
implementation("io.ktor:ktor-client-okhttp:3.x") // JVM
|
||||
implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS
|
||||
// Опционально — только если будешь использовать `InMemoryJournalStore`
|
||||
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
|
||||
api("pw.binom.agentik:journal-inmemory:0.1.0")
|
||||
}
|
||||
```
|
||||
|
||||
## Версии
|
||||
## Быстрый старт: свой клиент за 5 минут
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
||||
|
||||
Поддерживает все KMP-таргеты, что и `:proto`.
|
||||
|
||||
## Примеры API
|
||||
Один self-contained пример: создаём агента, открываем диалог,
|
||||
отправляем сообщение, печатаем streaming-ответ.
|
||||
|
||||
```kotlin
|
||||
// список диалогов
|
||||
agent.getConversations().collect { println(it.id to it.title) }
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.time.Clock
|
||||
|
||||
// live-подписка на события отдельного диалога
|
||||
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
||||
fun main() = runBlocking {
|
||||
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
|
||||
val agent = AgentikAgent(
|
||||
id = "my-client",
|
||||
baseUrl = "http://localhost:8080/agentik",
|
||||
engineFactory = CIO,
|
||||
token = "s3cret", // или null, если не нужен
|
||||
)
|
||||
|
||||
// прерывание текущего хода
|
||||
conversation.interrupt()
|
||||
// 2. Открыть диалог, отправить сообщение.
|
||||
val conv = agent.createConversation(temp = false)
|
||||
conv.send(listOf(Content.Text("Привет")))
|
||||
|
||||
// история
|
||||
conversation.getMessages(offset = 0).collect { msg ->
|
||||
when (msg) {
|
||||
is Message.UserMessage -> println("user: ${msg.content}")
|
||||
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Чистый shutdown.
|
||||
conv.close()
|
||||
agent.close()
|
||||
}
|
||||
```
|
||||
|
||||
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
|
||||
|
||||
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
||||
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||
|
||||
### Добавить локальный кэш истории (ещё 4 строки)
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import kotlin.time.Instant
|
||||
|
||||
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
|
||||
val cache = InMemoryJournalStore()
|
||||
|
||||
// Backfill + live-refresh в одном фоне:
|
||||
launch {
|
||||
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
|
||||
.collect { cache.append(it) }
|
||||
}
|
||||
|
||||
// История — теперь из кэша, без HTTP:
|
||||
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||
history.forEach { rec ->
|
||||
when (rec) {
|
||||
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
|
||||
is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
|
||||
is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
|
||||
is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
|
||||
is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Шаблон "remote.listFlow → local.append" работает с любым
|
||||
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
|
||||
"без геморроя".
|
||||
|
||||
### Что вообще не нужно писать самому
|
||||
|
||||
- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует
|
||||
`agentikJson` и `InstantSerializer`.
|
||||
- SSE-парсер — `readSse()` внутри `:client`.
|
||||
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
|
||||
`JournalStore.listFlow` сама пагинирует.
|
||||
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
|
||||
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
|
||||
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
|
||||
- Движковые настройки (requestTimeout и пр.) — `HttpClient(engineFactory) { ... }`
|
||||
создаётся здесь; для нестандартных движковых настроек используй
|
||||
`agentikHttpClient(engineFactory, token)` напрямую (он экспортирован).
|
||||
|
||||
### Что нужно написать самому
|
||||
|
||||
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
|
||||
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
|
||||
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
|
||||
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
|
||||
|
||||
|
||||
## Базовый пример: send + collect events
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
|
||||
val agent = AgentikAgent(
|
||||
id = "agentik",
|
||||
baseUrl = "http://localhost:8080/agentik",
|
||||
engineFactory = CIO,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## История с локальным кэшем
|
||||
|
||||
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
|
||||
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
|
||||
чтение истории — из локального кэша.
|
||||
|
||||
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
|
||||
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
|
||||
реализовывал интерфейс.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ChatSession(
|
||||
private val agent: pw.binom.agentik.proto.Agent,
|
||||
val conversationId: String,
|
||||
) : AutoCloseable {
|
||||
|
||||
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
|
||||
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
|
||||
// (модуль `:journal-api`).
|
||||
val cache = InMemoryJournalStore()
|
||||
|
||||
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
|
||||
private val scope = kotlinx.coroutines.CoroutineScope(
|
||||
kotlinx.coroutines.SupervisorJob() +
|
||||
kotlinx.coroutines.Dispatchers.Default,
|
||||
)
|
||||
|
||||
init {
|
||||
// 1. Backfill: забираем всю историю разговора с сервера.
|
||||
scope.launch {
|
||||
agent.journal.listFlow(
|
||||
conversationId = conversationId,
|
||||
after = Instant.DISTANT_PAST,
|
||||
).collect { cache.append(it) }
|
||||
}
|
||||
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
||||
scope.launch {
|
||||
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||
if (ev is Event.End) {
|
||||
val newest = cache.let {
|
||||
// last-seen курсор — последний createdAt в кэше
|
||||
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||
?: Instant.DISTANT_PAST
|
||||
}
|
||||
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
|
||||
.forEach { cache.append(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun history() = kotlinx.coroutines.runBlocking {
|
||||
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Использование:
|
||||
val session = ChatSession(agent, conv.id)
|
||||
|
||||
// История — из кэша:
|
||||
session.history().forEach { rec ->
|
||||
when (rec) {
|
||||
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
|
||||
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
|
||||
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
|
||||
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
|
||||
is MessageRecord.Error -> println("error: ${rec.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Отправить новое сообщение:
|
||||
session.scope.launch {
|
||||
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
|
||||
}
|
||||
```
|
||||
|
||||
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
|
||||
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
|
||||
`rec is MessageRecord.UserMessage` для реплик пользователя,
|
||||
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
|
||||
|
||||
## Стриминг live-ответа
|
||||
|
||||
Для streaming-рендера текущего хода подписывайся на `events()` и
|
||||
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
|
||||
только для UI-feedback во время хода. После `End` хода запись уже
|
||||
появится в кэше через refresh-блок выше.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.proto.Event
|
||||
|
||||
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Прерывание хода
|
||||
|
||||
```kotlin
|
||||
agent.getConversation(convId)!!.interrupt()
|
||||
```
|
||||
|
||||
## Multi-conversation
|
||||
|
||||
Один `Agent`, много `ChatSession`:
|
||||
|
||||
```kotlin
|
||||
val sessions = mutableMapOf<String, ChatSession>()
|
||||
|
||||
fun open(convId: String): ChatSession =
|
||||
sessions.getOrPut(convId) { ChatSession(agent, convId) }
|
||||
|
||||
fun close(convId: String) {
|
||||
sessions.remove(convId)?.close()
|
||||
}
|
||||
```
|
||||
|
||||
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
|
||||
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
|
||||
|
||||
## Где `:client` НЕ помогает
|
||||
|
||||
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
|
||||
отдаёт типы и потоки.
|
||||
- **Персистентность кэша** — `InMemoryJournalStore` хранит в RAM. Для
|
||||
диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в
|
||||
`:journal-ksqlite` как образец).
|
||||
- **Нестандартные движковые настройки** — для `requestTimeout`,
|
||||
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
|
||||
напрямую.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :client:jvmTest
|
||||
```
|
||||
|
||||
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
||||
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||
401/404.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого LLM-кода. Это просто клиент.
|
||||
- Никакого persistent state. История хранится у сервера, клиент её
|
||||
запрашивает через `getMessages` или подписывается через `events`.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
||||
но клиент совместим с любым сервером, который держит wire-контракт
|
||||
`:server`.
|
||||
|
||||
## Известное ограничение
|
||||
|
||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
||||
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||
|
||||
@@ -21,9 +21,10 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":proto"))
|
||||
api(project(":outbox-api"))
|
||||
api(project(":journal-api"))
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
api(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
@@ -37,6 +38,7 @@ kotlin {
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.test.host)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.server.cio)
|
||||
implementation(libs.ktor.server.sse)
|
||||
}
|
||||
|
||||
@@ -4,39 +4,38 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||
*
|
||||
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
|
||||
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
|
||||
* POST `/conversations`. Используем `runBlocking` — это одноразовая
|
||||
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
|
||||
* решает, что делать.
|
||||
* HttpClient создаётся внутри из переданного engine и закрывается в [close].
|
||||
*
|
||||
* **Storage handles** ([journal], [outbox]) — read-only views на серверные
|
||||
* хранилища.
|
||||
*/
|
||||
internal class AgentClient(
|
||||
private val httpClient: HttpClient,
|
||||
private val baseUrl: String,
|
||||
override val id: String,
|
||||
private val baseUrl: String,
|
||||
private val httpClient: HttpClient,
|
||||
) : Agent {
|
||||
|
||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation =
|
||||
runBlocking {
|
||||
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
||||
@@ -54,7 +53,7 @@ internal class AgentClient(
|
||||
}
|
||||
|
||||
override suspend fun deleteConversation(id: String): Boolean {
|
||||
val response = httpClient.delete("$agentUrl/conversations/$id")
|
||||
val response: HttpResponse = httpClient.delete("$agentUrl/conversations/$id")
|
||||
return response.status == HttpStatusCode.NoContent
|
||||
}
|
||||
|
||||
@@ -66,16 +65,7 @@ internal class AgentClient(
|
||||
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
||||
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
||||
}
|
||||
}
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.HttpClientEngineFactory
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
|
||||
* (модуль `:server`).
|
||||
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||
*
|
||||
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
|
||||
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
|
||||
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
|
||||
* снаружи — всё под капотом.
|
||||
*
|
||||
* ```
|
||||
* val client = AgentikAgent(
|
||||
* id = "my-agent",
|
||||
* val agent = AgentikAgent(
|
||||
* id = "my-client",
|
||||
* baseUrl = "http://localhost:8080/agentik",
|
||||
* engineFactory = CIO,
|
||||
* token = "s3cret",
|
||||
* )
|
||||
* val conv = client.createConversation(temp = false)
|
||||
* val conv = agent.createConversation(temp = false)
|
||||
* conv.send(listOf(Content.Text("hi")))
|
||||
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
|
||||
* conv.events(Instant.DISTANT_PAST).collect { ... }
|
||||
* agent.close() // закрывает HttpClient
|
||||
* ```
|
||||
*
|
||||
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
|
||||
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
||||
* конфига).
|
||||
* [id] пробрасывается в `Agent.id` — сервер про идентичность агента не знает,
|
||||
* поэтому клиент должен её знать сам (или взять из конфига).
|
||||
*
|
||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
|
||||
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
|
||||
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
||||
* HttpClient (идемпотентно). После этого `createConversation` /
|
||||
* `getConversation` etc. не определены.
|
||||
*/
|
||||
fun AgentikAgent(
|
||||
id: String,
|
||||
baseUrl: String,
|
||||
httpClient: HttpClient = defaultAgentikHttpClient(),
|
||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
||||
|
||||
/**
|
||||
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
|
||||
* живёт в общем коде и плагина `SSEClientContent` не требует.
|
||||
*
|
||||
* **Платформы:**
|
||||
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
|
||||
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
|
||||
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
|
||||
* `ConversationClient.events`/`AgentClient.events` уже ставят
|
||||
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
|
||||
*
|
||||
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
|
||||
* Реализация — в [HttpClientFactory.kt].
|
||||
*/
|
||||
engineFactory: HttpClientEngineFactory<*>,
|
||||
token: String? = null,
|
||||
): Agent = AgentClient(
|
||||
id = id,
|
||||
baseUrl = baseUrl,
|
||||
httpClient = agentikHttpClient(engineFactory = engineFactory, token = token),
|
||||
)
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.engine.HttpClientEngineFactory
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
|
||||
/**
|
||||
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
|
||||
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
|
||||
* Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
|
||||
*
|
||||
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
|
||||
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
|
||||
* Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
|
||||
* не нужен — он передаёт engine в [AgentikAgent] и получает готовый
|
||||
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
|
||||
* на [pw.binom.agentik.proto.Agent.close].
|
||||
*
|
||||
* Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
|
||||
* (например, дополнительные нестандартные запросы в обход `Agent` API).
|
||||
*/
|
||||
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
engine { requestTimeout = 0 }
|
||||
fun agentikHttpClient(
|
||||
engineFactory: HttpClientEngineFactory<*>,
|
||||
token: String? = null,
|
||||
): HttpClient = HttpClient(engineFactory) {
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
if (token != null) {
|
||||
install(DefaultRequest) {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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.OutboxStore
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]),
|
||||
* ходящая в `:server`-фасад.
|
||||
*
|
||||
* **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]):
|
||||
* - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток
|
||||
* [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes])
|
||||
* - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут:
|
||||
* сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает
|
||||
* `.event` для обратной совместимости с форматом AgentEvent)
|
||||
* - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events`
|
||||
*
|
||||
* Для [conversationEvents] с `conversationId == null` (события всех диалогов)
|
||||
* fallback на default [OutboxStore.conversationEvents] — общий поток
|
||||
* `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и
|
||||
* оптимизировать его отдельно нерационально.
|
||||
*
|
||||
* [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()`
|
||||
* (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть
|
||||
* контрактное значение). Клиент, который полагался на gap detection через
|
||||
* message store, продолжит работать — просто fallback никогда не сработает.
|
||||
*
|
||||
* **Импорты [CommonEvent]/[AgentEvent]/[Event] идут напрямую из
|
||||
* `pw.binom.agentik.outbox`** — typealias'ы в `:proto.CommonEvent` и т.п.
|
||||
* НЕ поддерживают nested-class access (`CommonEvent.Agent` через alias
|
||||
* даёт "Unresolved qualified name"), поэтому приходится использовать
|
||||
* конкретный пакет. Типы идентичны, alias только для удобства внешнего API.
|
||||
*/
|
||||
internal class HttpEventStore(
|
||||
private val httpClient: HttpClient,
|
||||
private val baseUrl: String,
|
||||
) : OutboxStore {
|
||||
|
||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||
val url = buildString {
|
||||
append("$agentUrl/outbox/events")
|
||||
if (after != null) append("?after=$after")
|
||||
}
|
||||
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(CommonEvent.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события.
|
||||
* Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`.
|
||||
*/
|
||||
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = flow {
|
||||
val url = buildString {
|
||||
append("$agentUrl/events")
|
||||
if (after != null) append("?after=$after")
|
||||
}
|
||||
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"agentEvents: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
val event = agentikJson.decodeFromString(AgentEvent.serializer(), payload)
|
||||
emit(CommonEvent.Agent(date = event.date, event = event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override с `conversationId != null` — идём в `/conversations/{id}/events`.
|
||||
* С `null` (события всех диалогов) — fallback на default impl из [EventStore]:
|
||||
* общий `/events/all` + filter.
|
||||
*/
|
||||
override fun conversationEvents(
|
||||
after: Instant?,
|
||||
conversationId: String?,
|
||||
): Flow<CommonEvent.Conversation> {
|
||||
if (conversationId == null) {
|
||||
return super.conversationEvents(after, null)
|
||||
}
|
||||
return flow {
|
||||
val url = buildString {
|
||||
append("$agentUrl/conversations/$conversationId/events")
|
||||
if (after != null) append("?after=$after")
|
||||
}
|
||||
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"conversationEvents: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
val event = agentikJson.decodeFromString(Event.serializer(), payload)
|
||||
emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* У HTTP-варианта нет своего endpoint'а для earliest-event-date.
|
||||
* Контракт [EventStore.earliestEventDate] для пустого буфера говорит
|
||||
* "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст"
|
||||
* (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`.
|
||||
*/
|
||||
override suspend fun earliestEventDate(): Instant = Clock.System.now()
|
||||
|
||||
override fun close() {
|
||||
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
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]).
|
||||
*
|
||||
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
|
||||
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
|
||||
* в `:server`'s proto-роутах (который отдаёт project'нутые
|
||||
* [pw.binom.agentik.proto.Message]), здесь клиент получает полный transcript
|
||||
* с tool-call/tool-result/error payload'ами, turn-tokens и context'ом.
|
||||
*
|
||||
* **listFlow** — default cold-flow paging через [list] (N+1 round-trip,
|
||||
* дефолтная реализация из [JournalStore]). Для remote/SQL-backed store'а
|
||||
* это OK: server-side paging + client-side flow compose'ится естественно.
|
||||
*
|
||||
* **Read-only**: [JournalStore] не имеет `append` — запись только через
|
||||
* writer-референс, который ChatAgent держит внутри (тип
|
||||
* `MutableJournalStore`, не выставлен наружу через [pw.binom.agentik.proto.Agent]).
|
||||
*/
|
||||
internal class HttpJournalStore(
|
||||
private val httpClient: HttpClient,
|
||||
private val baseUrl: String,
|
||||
) : JournalStore {
|
||||
|
||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
after: Instant,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
): List<MessageRecord> {
|
||||
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/messages") {
|
||||
parameter("after", after.toString())
|
||||
parameter("offset", offset)
|
||||
parameter("limit", limit)
|
||||
}
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"journal.list: server returned ${response.status}"
|
||||
}
|
||||
return response.body<List<MessageRecord>>()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import io.ktor.client.request.HttpRequestBuilder
|
||||
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
||||
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
|
||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||
*
|
||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.application.createRouteScopedPlugin
|
||||
import io.ktor.server.cio.CIO as ServerCIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Тесты клиентской части: [agentikHttpClient] с заданным `token` прикладывает
|
||||
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
||||
* без токена — заголовок не отправляется.
|
||||
*
|
||||
* Сервер в тесте — локальный ktor-CIO с inline route-scoped Bearer-плагином (один в один
|
||||
* как боевой [pw.binom.agentik.server.BearerTokenPlugin]). Тестовый `:server` не зависит
|
||||
* от `:client`, поэтому боевой плагин тут переиспользовать нельзя — пересоздаём его
|
||||
* минимально, контракт тот же.
|
||||
*/
|
||||
class BearerHeaderTest {
|
||||
|
||||
private val TestBearer = createRouteScopedPlugin(
|
||||
name = "TestBearer",
|
||||
createConfiguration = ::BearerCfg,
|
||||
) {
|
||||
val expected = pluginConfig.token
|
||||
onCall { call ->
|
||||
if (expected == null) return@onCall
|
||||
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
|
||||
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BearerCfg {
|
||||
var token: String? = null
|
||||
}
|
||||
|
||||
private fun clientWith(token: String?): HttpClient =
|
||||
agentikHttpClient(engineFactory = CIO, token = token)
|
||||
|
||||
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
||||
val server = embeddedServer(ServerCIO, port = 0) {
|
||||
routing {
|
||||
route("/agentik") {
|
||||
install(TestBearer) { token = "secret" }
|
||||
get("/conversations") {
|
||||
call.respondText("[]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
return server to port
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithTokenAttachesBearerHeader() = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = clientWith("secret")
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
assertEquals("[]", resp.bodyAsText())
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithoutTokenGets401(): Unit = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = clientWith(null)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithWrongTokenGets401(): Unit = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = clientWith("wrong")
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// Public API для runtime context агента (compaction, order_idx, summary entries).
|
||||
// Зависит от :journal-api для типов `Content` / `MessageContext` (audit-log
|
||||
// payload'ы, которые рабочая память ссылает).
|
||||
//
|
||||
// НЕ нужен тонким клиентам — только серверному рантайму (`:standalone`, `:agentik-cli`,
|
||||
// будущий `:android-agent` core).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":journal-api"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.storage
|
||||
package pw.binom.agentik.context
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -7,7 +7,7 @@ import kotlin.time.Instant
|
||||
*
|
||||
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
||||
* Агент не должен с этим типом работать напрямую — он работает с
|
||||
* [WorkingMemoryEntry] через [WorkingMemoryStore].
|
||||
* [WorkingMemoryEntry] через [ContextStore].
|
||||
*/
|
||||
data class WorkingMemoryRow(
|
||||
val id: String,
|
||||
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
|
||||
*
|
||||
* Суммаризация / чистка — один атомарный вызов [compact].
|
||||
*/
|
||||
interface WorkingMemoryStore : AutoCloseable {
|
||||
interface ContextStore : AutoCloseable {
|
||||
|
||||
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
package pw.binom.agentik.storage
|
||||
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
|
||||
|
||||
/**
|
||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||
@@ -0,0 +1,41 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// KMP-реализация :context-api (ContextStore) поверх ksqlite.
|
||||
// Минимальная — только таблица `working_memory` + 2 индекса по ней.
|
||||
// Остальные таблицы (`conversation`, `message`, `reflection`) живут в
|
||||
// других ksqlite-модулях; этот модуль не претендует на полную схему
|
||||
// агента.
|
||||
//
|
||||
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||
// на Linux (см. KDoc :storage-ksqlite).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT")
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
api(project(":context-api"))
|
||||
api(project(":journal-api"))
|
||||
api(project(":reflection-api"))
|
||||
// :context-api ссылается на Content / MessageContext из
|
||||
// :message-log-api (старый canonical). Транзитивно через api,
|
||||
// но фиксируем явно чтобы тестовый код видел Content без
|
||||
// обхода через :context-api.
|
||||
// api(project(":message-log-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package pw.binom.agentik.context.ksqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||
import pw.binom.agentik.context.WorkingMemoryRow
|
||||
import pw.binom.agentik.journal.Ids
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
|
||||
*
|
||||
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
|
||||
* из `:storage-ksqlite`, но:
|
||||
* - лежит в собственном модуле `:context-ksqlite`;
|
||||
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
|
||||
* теперь главный класс — `ContextStore`); сами типы строк
|
||||
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
|
||||
*
|
||||
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
|
||||
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
|
||||
*/
|
||||
class KsqliteContextStore(
|
||||
private val connection: SQLiteConnection,
|
||||
) : ContextStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
|
||||
// SIGSEGV в StmtHolder.finalize на закрытой connection).
|
||||
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
)
|
||||
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
ORDER BY ${Schema.COL_ORDER_IDX} ASC
|
||||
""".trimIndent()
|
||||
)
|
||||
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||
)
|
||||
private val maxOrderIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
|
||||
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val dropFromIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val insertSummaryStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val newIdx = maxOrderIdx(conversationId) + 1
|
||||
insertStmt.reset()
|
||||
insertStmt.clearBindings()
|
||||
insertStmt.bindText(1, Ids.new("wm"))
|
||||
insertStmt.bindText(2, conversationId)
|
||||
insertStmt.bindLong(3, newIdx)
|
||||
val srcId = entry.sourceMessageId
|
||||
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
|
||||
insertStmt.bindText(5, entryKind(entry))
|
||||
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
|
||||
insertStmt.bindLong(7, now.toEpochMilliseconds())
|
||||
insertStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
listStmt.reset()
|
||||
listStmt.clearBindings()
|
||||
listStmt.bindText(1, conversationId)
|
||||
val out = mutableListOf<WorkingMemoryRow>()
|
||||
listStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
out.add(
|
||||
WorkingMemoryRow(
|
||||
id = rs.getText(0)!!,
|
||||
conversationId = rs.getText(1)!!,
|
||||
orderIdx = rs.getLong(2)!!,
|
||||
sourceMessageId = rs.getText(3),
|
||||
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
|
||||
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
clearStmt.reset()
|
||||
clearStmt.clearBindings()
|
||||
clearStmt.bindText(1, conversationId)
|
||||
clearStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun compact(
|
||||
dropFromOrderIdx: Long,
|
||||
conversationId: String,
|
||||
summaryText: String?,
|
||||
): Long = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
var newMax = 0L
|
||||
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||
val summaryId = Ids.new("wm")
|
||||
connection.exec("BEGIN")
|
||||
try {
|
||||
dropFromIdxStmt.reset()
|
||||
dropFromIdxStmt.clearBindings()
|
||||
dropFromIdxStmt.bindText(1, conversationId)
|
||||
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
|
||||
dropFromIdxStmt.executeUpdate()
|
||||
|
||||
if (!summaryText.isNullOrBlank()) {
|
||||
val afterDelete = maxOrderIdx(conversationId)
|
||||
val newIdx = afterDelete + 1
|
||||
insertSummaryStmt.reset()
|
||||
insertSummaryStmt.clearBindings()
|
||||
insertSummaryStmt.bindText(1, summaryId)
|
||||
insertSummaryStmt.bindText(2, conversationId)
|
||||
insertSummaryStmt.bindLong(3, newIdx)
|
||||
insertSummaryStmt.bindText(4, "summary")
|
||||
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
|
||||
insertSummaryStmt.bindLong(6, nowMs)
|
||||
insertSummaryStmt.executeUpdate()
|
||||
newMax = newIdx
|
||||
} else {
|
||||
newMax = maxOrderIdx(conversationId)
|
||||
}
|
||||
connection.exec("COMMIT")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { connection.exec("ROLLBACK") }
|
||||
throw t
|
||||
}
|
||||
newMax
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
insertStmt.close()
|
||||
listStmt.close()
|
||||
clearStmt.close()
|
||||
maxOrderIdxStmt.close()
|
||||
dropFromIdxStmt.close()
|
||||
insertSummaryStmt.close()
|
||||
}
|
||||
|
||||
private fun maxOrderIdx(conversationId: String): Long {
|
||||
maxOrderIdxStmt.reset()
|
||||
maxOrderIdxStmt.clearBindings()
|
||||
maxOrderIdxStmt.bindText(1, conversationId)
|
||||
maxOrderIdxStmt.executeQuery().use { rs ->
|
||||
if (rs.next()) return rs.getLong(0) ?: 0L
|
||||
}
|
||||
return 0L
|
||||
}
|
||||
|
||||
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
|
||||
is WorkingMemoryEntry.User -> "user"
|
||||
is WorkingMemoryEntry.Assistant -> "assistant"
|
||||
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
|
||||
is WorkingMemoryEntry.Summary -> "summary"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package pw.binom.agentik.context.ksqlite
|
||||
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:context-api`.
|
||||
*
|
||||
* Минимум — только то, что относится к `working_memory` (реализация
|
||||
* [KsqliteContextStore]). Остальные таблицы агента (`conversation`,
|
||||
* `message`, `reflection`) живут в других ksqlite-модулях.
|
||||
*
|
||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||
*/
|
||||
internal object Schema {
|
||||
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
|
||||
// ───── Таблица ─────
|
||||
const val TABLE_WORKING_MEMORY = "working_memory"
|
||||
|
||||
// ───── Колонки ─────
|
||||
const val COL_ID = "id"
|
||||
const val COL_CONVERSATION_ID = "conversation_id"
|
||||
const val COL_ORDER_IDX = "order_idx"
|
||||
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
|
||||
const val COL_KIND = "kind"
|
||||
const val COL_PAYLOAD_JSON = "payload_json"
|
||||
const val COL_CREATED_AT = "created_at"
|
||||
|
||||
// ───── Индексы ─────
|
||||
const val IDX_WM_UNIQUE = "idx_wm_unique"
|
||||
const val IDX_WM_CONV = "idx_wm_conv"
|
||||
|
||||
private val v1Ddl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||
$COL_ORDER_IDX INTEGER NOT NULL,
|
||||
$COL_SOURCE_MESSAGE_ID TEXT,
|
||||
$COL_KIND TEXT NOT NULL,
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL
|
||||
);
|
||||
""".trimIndent()
|
||||
|
||||
private val v1IndexesDdl = """
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
|
||||
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
|
||||
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||
""".trimIndent()
|
||||
|
||||
/**
|
||||
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||
*
|
||||
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
|
||||
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
|
||||
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
|
||||
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
|
||||
*
|
||||
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||
*/
|
||||
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("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")
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
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.db.ksqlite.SQLiteConnection
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
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` — этот модуль
|
||||
* автономный.
|
||||
*/
|
||||
class KsqliteContextStoreTest {
|
||||
|
||||
private lateinit var conn: SQLiteConnection
|
||||
private lateinit var store: KsqliteContextStore
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
|
||||
Schema.migrate(conn)
|
||||
store = KsqliteContextStore(conn)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
store.close()
|
||||
conn.close()
|
||||
}
|
||||
|
||||
private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User =
|
||||
WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content)))
|
||||
|
||||
private fun asstMsg(content: String): WorkingMemoryEntry.Assistant =
|
||||
WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content)))
|
||||
|
||||
@Test
|
||||
fun testAppendAndListReturnsInOrder() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", userMsg("first"), t)
|
||||
store.append("c1", asstMsg("reply"), t)
|
||||
val list = store.list("c1")
|
||||
assertEquals(2, list.size)
|
||||
assertEquals(1L, list[0].orderIdx)
|
||||
assertEquals(2L, list[1].orderIdx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListIsolatesConversations() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", userMsg("c1-msg"), t)
|
||||
store.append("c2", userMsg("c2-msg"), t)
|
||||
assertEquals(1, store.list("c1").size)
|
||||
assertEquals(1, store.list("c2").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearRemovesAllForConversation() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", userMsg("a"), t)
|
||||
store.append("c1", userMsg("b"), t)
|
||||
store.clear("c1")
|
||||
assertEquals(emptyList(), store.list("c1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCompactDeletesAndInsertsSummary() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", userMsg("a"), t)
|
||||
store.append("c1", userMsg("b"), t)
|
||||
store.append("c1", userMsg("c"), t)
|
||||
// dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a).
|
||||
// Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2.
|
||||
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary")
|
||||
assertEquals(2L, newMax)
|
||||
val remaining = store.list("c1")
|
||||
assertEquals(2, remaining.size)
|
||||
assertEquals(1L, remaining[0].orderIdx)
|
||||
assertEquals(2L, remaining[1].orderIdx)
|
||||
assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCompactWithoutSummaryKeepsTailBelow() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", userMsg("a"), t)
|
||||
// dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1.
|
||||
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
|
||||
assertEquals(1L, newMax)
|
||||
val remaining = store.list("c1")
|
||||
assertEquals(1, remaining.size)
|
||||
assertEquals(1L, remaining[0].orderIdx)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ Main.kt
|
||||
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
||||
summarization-вставка отложена (нужен дизайн-проработка).
|
||||
|
||||
**`MessageStore`** — append-only аудит. На каждый ход дописываются
|
||||
**`JournalStore`** — append-only аудит. На каждый ход дописываются
|
||||
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
||||
update/delete кроме каскада из `ConversationStore.delete`.
|
||||
|
||||
|
||||
+2
-2
@@ -175,7 +175,7 @@ fun main() {
|
||||
|
||||
**Ошибки хода персистятся.** Если ход провалился (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` не пишется (модель не должна видеть ошибки прошлых ходов).
|
||||
|
||||
### `MessageStore`
|
||||
### `JournalStore`
|
||||
|
||||
```kotlin
|
||||
suspend fun append(record: MessageRecord)
|
||||
@@ -183,7 +183,7 @@ suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int
|
||||
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||
```
|
||||
|
||||
### `WorkingMemoryStore`
|
||||
### `ContextStore`
|
||||
|
||||
```kotlin
|
||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# 01 — Слои модулей (целевое состояние)
|
||||
|
||||
Целевая модульная структура agentik. Снизу вверх:
|
||||
**приложения → runtime → домен → абстракции → платформенные impl**.
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml agentik-module-layers
|
||||
skinparam componentStyle rectangle
|
||||
skinparam ranksep 60
|
||||
skinparam nodesep 30
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title agentik — слои модулей (целевое состояние)
|
||||
|
||||
' --- Applications: entry points (thin wrappers) ---
|
||||
package "Applications\n(entry points, тонкие)" {
|
||||
[Standalone\nHTTP+AG-UI+A2A] as Standalone
|
||||
[AgentikCli\nREPL] as Cli
|
||||
[AgentikAndroid\nCompose UI] as Android
|
||||
}
|
||||
|
||||
' --- Agent runtime ---
|
||||
package "Agent Runtime\n(композиция, lifecycle)" {
|
||||
[AgentCore\nBaseAgent] as AgentCore
|
||||
[AgentBuilder\nDSL] as Builder
|
||||
}
|
||||
|
||||
' --- Background work ---
|
||||
package "Background Work\n(event-driven triggers)" {
|
||||
[BackgroundEvents\nbus + events] as Ev
|
||||
[BackgroundScheduler\npolicy] as Sched
|
||||
}
|
||||
|
||||
' --- Domain logic (generic, переиспользуется) ---
|
||||
package "Domain Logic\n(generic tools)" {
|
||||
[LlmTools\nReflector/Reviewer/Miner] as LlmT
|
||||
[McpBridge\nMCP-SDK → LiteTool] as Mcp
|
||||
[Skills\nparse + store] as Skills
|
||||
}
|
||||
|
||||
' --- Storage abstractions + impls ---
|
||||
package "Storage\n(abstractions)" as StoragePkg {
|
||||
[StorageCore\ninterfaces] as StorageCore
|
||||
}
|
||||
|
||||
package "Storage\n(JVM impls)" {
|
||||
[StorageSqlite\nJDBC] as StorageSql
|
||||
[StorageInmemory\ntests] as StorageInmem
|
||||
}
|
||||
|
||||
package "Storage\n(Android impl)" {
|
||||
[StorageSqliteAndroid\nRoom/sqlite] as StorageSqlA
|
||||
}
|
||||
|
||||
' --- Memory backends ---
|
||||
package "Memory\n(abstractions)" {
|
||||
[MemoryApi\nMemorySystem/MemoryTools] as MemApi
|
||||
}
|
||||
|
||||
package "Memory\n(impls)" {
|
||||
[MemoryMd\nHermes §-files] as MemMd
|
||||
[MemoryVector\nJVector+JVM] as MemVec
|
||||
[MemoryVectorAndroid\nONNX+ANN] as MemVecA
|
||||
}
|
||||
|
||||
' --- LLM backends ---
|
||||
package "LLM\n(abstractions)" {
|
||||
[LitertApi\nLiteLlm контракт] as Litert
|
||||
}
|
||||
|
||||
package "LLM\n(impls)" {
|
||||
[LitertOpenai\nHTTP] as LitertO
|
||||
[LitertGoogle\nLiteRT JVM] as LitertG
|
||||
[LitertAndroid\nLiteRT Android] as LitertA
|
||||
}
|
||||
|
||||
' --- Inter-app protocol ---
|
||||
package "Inter-app" {
|
||||
[Proto\nAgent/Conversation] as Proto
|
||||
[A2AServer] as A2A
|
||||
}
|
||||
|
||||
' --- Зависимости (приложения → runtime → домен → абстракции → платформенные импл) ---
|
||||
Standalone ..> Builder
|
||||
Cli ..> Builder
|
||||
Android ..> Builder
|
||||
|
||||
Builder ..> AgentCore
|
||||
AgentCore ..> Proto
|
||||
AgentCore ..> StorageCore
|
||||
AgentCore ..> MemApi
|
||||
AgentCore ..> Litert
|
||||
AgentCore ..> Mcp
|
||||
AgentCore ..> Skills
|
||||
|
||||
Sched ..> Ev
|
||||
AgentCore ..> Sched
|
||||
AgentCore ..> Ev
|
||||
|
||||
Mcp ..> Litert
|
||||
LlmT ..> Litert
|
||||
|
||||
MemMd ..> MemApi
|
||||
MemVec ..> MemApi
|
||||
MemVecA ..> MemApi
|
||||
|
||||
StorageSql ..> StorageCore
|
||||
StorageInmem ..> StorageCore
|
||||
StorageSqlA ..> StorageCore
|
||||
|
||||
LitertO ..> Litert
|
||||
LitertG ..> Litert
|
||||
LitertA ..> Litert
|
||||
|
||||
Standalone ..> A2A
|
||||
Standalone ..> LitertO
|
||||
Standalone ..> LitertG
|
||||
Standalone ..> StorageSql
|
||||
Standalone ..> MemMd
|
||||
Standalone ..> MemVec
|
||||
Standalone ..> Mcp
|
||||
|
||||
Android ..> LitertA
|
||||
Android ..> StorageSqlA
|
||||
Android ..> MemMd
|
||||
Android ..> MemVecA
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Что показывает
|
||||
|
||||
- **Applications** — три точки входа: web-сервер, CLI REPL, Android-приложение. Каждое тонкое, не содержит бизнес-логики.
|
||||
- **Agent Runtime** — `BaseAgent` + `AgentBuilder` DSL. Вся композиция и lifecycle.
|
||||
- **Background Work** — `BackgroundEvents` (event-bus) + `BackgroundScheduler` (policy подписки). Event-driven, не interval-polling.
|
||||
- **Domain Logic** — generic переиспользуемые модули (`:llm-tools`, `:mcp-bridge`, `:skills`).
|
||||
- **Storage / Memory / LLM** — каждая с абстракцией и одним или несколькими impl (JVM-only или Android-only).
|
||||
- **Inter-app** — `:proto` контракты + `:a2a-server` для межагентного общения.
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ Уже сделано (в этом цикле правок):
|
||||
- `:llm-tools` extracted
|
||||
- `:mcp-bridge` extracted
|
||||
- `BackgroundScheduler` стал event-driven
|
||||
- `ConversationLoop` стал отдельным компонентом (typealias `ChatConversation`)
|
||||
|
||||
⏳ Не сделано:
|
||||
- `:agent-core` (выделить `BaseAgent` + builder в отдельный KMP-модуль)
|
||||
- `:background-events` (выделить events + scheduler — пока в `:standalone`)
|
||||
- `:storage-sqlite-android`
|
||||
- `:memory-vector-android`
|
||||
- `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,127 @@
|
||||
# 02 — Agent Builder: композиция (целевое API)
|
||||
|
||||
Как `AgentBuilder` собирает `BaseAgent` из компонентов. **Memory backend сам объявляет свои tools** — builder их авто-мержит. BackgroundScheduler подписан на события, не interval-poll.
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml agent-composition
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Agent Builder — композиция (целевое API)
|
||||
|
||||
' --- Builder ---
|
||||
rectangle "AgentBuilder" as Builder {
|
||||
rectangle "llm: LiteLlm (обязательно)" as Llm
|
||||
rectangle "storage: StorageBundle (обязательно)" as Storage
|
||||
rectangle "memory: MemorySystem (обязательно)" as Mem
|
||||
rectangle "soul: SoulProvider (default NoopSoul)" as Soul
|
||||
rectangle "tools: List<NamedTool> (авто-сборка из backends)" as Tools
|
||||
rectangle "background: BackgroundConfig (default EmptyBg)" as Bg
|
||||
rectangle "toolset: List<ToolsetContribution> (default empty)" as Ts
|
||||
}
|
||||
|
||||
' --- Backends with their tool side-effects ---
|
||||
rectangle "MemoryMd" as MdMem {
|
||||
interface "MemorySystem" as MemSys
|
||||
interface "List<NamedTool>" as MdTools
|
||||
note right
|
||||
MemoryMd.exposesTools() →
|
||||
memory_save / memory_read /
|
||||
memory_list / memory_delete
|
||||
end note
|
||||
}
|
||||
|
||||
rectangle "McpRegistry" as McpReg {
|
||||
interface "List<NamedTool>" as McpTools
|
||||
note right
|
||||
McpRegistry.namedTools →
|
||||
server__tool1, server__tool2,
|
||||
...
|
||||
end note
|
||||
}
|
||||
|
||||
rectangle "BackgroundEvents" as Events {
|
||||
interface "MutableSharedFlow<CompactionEvent|ToolCallEvent|LifecycleEvent>" as Flow
|
||||
note right
|
||||
Эмитится из:
|
||||
- CompactionCoordinator
|
||||
- ToolDispatcher
|
||||
- ConversationLoop.close()
|
||||
end note
|
||||
}
|
||||
|
||||
rectangle "BackgroundScheduler" as Sched {
|
||||
interface "policy: trigger + debounce" as Policy
|
||||
note right
|
||||
Подписан на Events.
|
||||
НИКАКОГО interval-polling.
|
||||
end note
|
||||
}
|
||||
|
||||
' --- Получаемый Agent ---
|
||||
rectangle "BaseAgent\n(impl: ConversationLoop)" as Agent {
|
||||
rectangle "send / interrupt / events" as API
|
||||
rectangle "BackgroundScheduler\nподписка" as Sub
|
||||
}
|
||||
|
||||
' --- Стрелки зависимостей ---
|
||||
Builder --> Llm
|
||||
Builder --> Storage
|
||||
Builder --> Mem
|
||||
Builder --> Soul
|
||||
Builder --> Tools
|
||||
Builder --> Bg
|
||||
Builder --> Ts
|
||||
|
||||
MdMem --> Mem : implements
|
||||
MdMem --> MdTools : exposes
|
||||
|
||||
McpReg --> McpTools : exposes
|
||||
|
||||
Bg --> Events : subscribes-to
|
||||
Bg --> Sched : holds
|
||||
|
||||
Tools <-- MdTools : auto-merge
|
||||
Tools <-- McpTools : auto-merge
|
||||
|
||||
Builder --> Agent : build()
|
||||
Agent --> API
|
||||
Agent --> Sub
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Целевой Kotlin DSL
|
||||
|
||||
```kotlin
|
||||
val agent = agentBuilder {
|
||||
// Обязательные
|
||||
llm(OpenAiLlm.fromEnv()) // или LitertAndroid.onDevice(context)
|
||||
storage(SqliteStorage(path)) // или SqliteStorage.android(context)
|
||||
memory(MemoryMd(root = "~/memory")) // или MemoryVector(embedding = HttpEmbedding(...))
|
||||
|
||||
// Опциональные
|
||||
soul(FileSoul("~/SOUL.md")) // или HttpSoul(url), NoopSoul()
|
||||
background {
|
||||
// triggers: OnClosing (reflection+mining), OnCompaction(minTurns=10, mining=true)
|
||||
// event-driven, не interval
|
||||
}
|
||||
tools {
|
||||
// memoryMd.exposesTools() + mcpRegistry.namedTools авто-подцепляются
|
||||
+FileReadTool(root = "/data")
|
||||
}
|
||||
toolset {
|
||||
+MemoryToolsToolset(memoryMd)
|
||||
}
|
||||
}.build()
|
||||
```
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
- **`MemoryBackend.exposesTools()`** — backend декларирует свои tools. Не «подставить любой backend», а «backend сообщает что он умеет». Это убирает coupling «какие tools совместимы с какими backends».
|
||||
- **Builder требует только `llm + storage + memory`** как обязательные. Всё остальное — опционально с разумными default'ами (`NoopSoul`, `EmptyBackground`, `empty toolset`).
|
||||
- **`BaseAgent`** — реализация `ConversationLoop` через builder. Конструктор принимает все нужные компоненты. **Один и тот же `BaseAgent` в `:standalone`, `:agentik-cli`, `:agentik-android`** — отличается только wiring через builder.
|
||||
- **BackgroundScheduler подписан на `BackgroundEvents`** — это даёт event-driven по умолчанию. `OnEvery(n)` interval-режим — опциональный fallback (не default).
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,129 @@
|
||||
# 03 — Multi-user chat с mention-detection
|
||||
|
||||
Точка 1 из планов. Один `BaseAgent` обслуживает N пользователей. Отвечает только когда addressed (mention или admin-команда).
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml multi-user-chat
|
||||
skinparam componentStyle rectangle
|
||||
skinparam participantPadding 15
|
||||
skinparam boxPadding 10
|
||||
|
||||
title Multi-user chat с mention-detection (точка 1 из планов)
|
||||
|
||||
' --- Участники ---
|
||||
actor "User A" as UA
|
||||
actor "User B" as UB
|
||||
actor "User C\n(админ)" as UC
|
||||
participant "Telegram /\nSlack /\nMatrix" as Channel
|
||||
participant "AgentRuntime\n(BaseAgent)" as Runtime
|
||||
participant "MentionDetector" as Detector
|
||||
participant "SoulProvider" as Soul
|
||||
participant "MemorySystem\n(MdMemory)" as Memory
|
||||
participant "LlmBackend\n(LiteLlm)" as Llm
|
||||
|
||||
' --- Сценарий ---
|
||||
UA -> Channel : "@bot, что нового?"
|
||||
UB -> Channel : "люблю котов"
|
||||
UC -> Channel : "/bot status"
|
||||
Channel -> Runtime : событие чата
|
||||
|
||||
' --- Внутри Runtime ---
|
||||
Runtime -> Detector : isMentioned(message, botName)
|
||||
|
||||
note right of Detector
|
||||
variants:
|
||||
- SimpleMentionDetector (regex: @bot)
|
||||
- LlmMentionDetector (mini-classifier)
|
||||
- AdminCommandDetector (/command)
|
||||
end note
|
||||
|
||||
Detector --> Runtime : MatchResult{isMentioned, isCommand}
|
||||
|
||||
alt isMentioned или isCommand
|
||||
Runtime -> Soul : read()
|
||||
Runtime -> Memory : prefetch(query, topK)
|
||||
Runtime -> Llm : send(system + history + memory + user)
|
||||
Llm --> Runtime : response + tool_calls
|
||||
Runtime -> Memory : save(decision)
|
||||
Runtime --> Channel : ответ в нужный канал/thread
|
||||
else NOT mentioned и NOT command
|
||||
Runtime -> Runtime : drop (если не админ)
|
||||
note right
|
||||
Не отвечаем, но возможно:
|
||||
- запоминаем факт (memory-only update)
|
||||
- summary на long conversation
|
||||
end note
|
||||
end
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Ключевые модули (что нужно будет добавить)
|
||||
|
||||
### `MentionDetector` interface
|
||||
|
||||
```kotlin
|
||||
interface MentionDetector {
|
||||
data class Result(
|
||||
val isMentioned: Boolean,
|
||||
val isAdminCommand: Boolean,
|
||||
val isPrivateMessage: Boolean, // DM — всегда отвечаем
|
||||
)
|
||||
|
||||
fun detect(message: ChatMessage, botName: String): Result
|
||||
}
|
||||
```
|
||||
|
||||
Имплементации:
|
||||
- `SimpleMentionDetector` — regex `@bot`, `/command` (дешёво, latency ~0)
|
||||
- `LlmMentionDetector` — маленькая классификация через тот же LLM (точнее, но +1 LLM-вызов на каждое сообщение)
|
||||
- `HybridMentionDetector` — fast regex → fallback на LLM только если ambiguous
|
||||
|
||||
### `ChatAdapter` interface
|
||||
|
||||
```kotlin
|
||||
interface ChatAdapter {
|
||||
val channel: String // "telegram" / "slack" / "matrix"
|
||||
suspend fun listen(onMessage: (ChatMessage) -> Unit): Job
|
||||
suspend fun reply(messageId: String, text: String, threadId: String? = null)
|
||||
suspend fun isAdmin(userId: String): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
Имплементации per platform. Каждая адаптирует формат platform → `ChatMessage`.
|
||||
|
||||
### Конфигурация builder'а
|
||||
|
||||
```kotlin
|
||||
agentBuilder {
|
||||
llm(...)
|
||||
storage(...)
|
||||
memory(...)
|
||||
soul(...)
|
||||
background { ... }
|
||||
chat {
|
||||
mentionDetector = HybridMentionDetector(regex = "@bot|@Agent", llmClassifier = false)
|
||||
chatAdapter = TelegramChatAdapter(token = "...")
|
||||
// На каждое сообщение:
|
||||
// 1. mentionDetector.detect()
|
||||
// 2. если isMentioned || isAdminCommand || isPrivate → process
|
||||
// 3. иначе — опционально memory-only save (тихий режим)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Что это даёт
|
||||
|
||||
- Один `BaseAgent` обслуживает чат целиком (один LLM, одна память — общий контекст команды)
|
||||
- `@bot` — explicit invocation, не «agent отвечает на всё подряд»
|
||||
- `/bot status` / `/bot clear-memory` — admin-команды (отдельный канал, без LLM)
|
||||
- DM — всегда отвечает (это личное обращение)
|
||||
- В групповом чате без mention — agent может **молча учить** (memory update без ответа). Полезно для «запомнил что Вася любит котов».
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
⏳ Ничего из этого нет. Сейчас `:standalone` — это HTTP API, к которому подключаются clients. Для multi-user chat нужен новый `:chat-adapter-telegram` (или -slack / -matrix) модуль + `MentionDetector` interface.
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,119 @@
|
||||
# 04 — Sub-agents + A2A между агентами
|
||||
|
||||
Точки 2 и 3 из планов. Orchestrator-агент spawn'ит sub-агентов с изолированным контекстом. Независимые агенты общаются через A2A.
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml sub-agents-and-a2a
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Sub-agents + A2A между агентами (точка 2+3 из планов)
|
||||
|
||||
' --- Orchestrator ---
|
||||
rectangle "OrchestratorAgent\n(BaseAgent + tools)" as Orch {
|
||||
rectangle "ConversationLoop\n(main user)" as MainConv
|
||||
}
|
||||
|
||||
' --- Sub-agent spawn ---
|
||||
rectangle "subAgent(\n task: String,\n config: AgentConfig\n): Flow<SubAgentEvent>" as SpawnAPI
|
||||
note right of SpawnAPI
|
||||
Spawn API — НЕ отдельный модуль,
|
||||
а convenience поверх BaseAgent:
|
||||
val sub = agent.spawnChild(config) {
|
||||
systemPrompt = "..."
|
||||
tools = [ReadTool, WriteTool]
|
||||
memory = EmptyMemory // изолированно
|
||||
}
|
||||
sub.events.collect { ... }
|
||||
end note
|
||||
|
||||
' --- Дочерний агент (изолированный контекст) ---
|
||||
rectangle "SubAgent\n(изолированный scope)" as Sub {
|
||||
rectangle "ConversationLoop\n(child)" as SubConv
|
||||
rectangle "backgroundScope\n(lifecycle scoped)" as SubBg
|
||||
}
|
||||
|
||||
' --- A2A между независимыми агентами ---
|
||||
rectangle "Agent A\n(BaseAgent)" as AgentA
|
||||
rectangle "Agent B\n(BaseAgent)" as AgentB
|
||||
rectangle "A2A Server\n(:a2a-server)" as A2ASrv
|
||||
|
||||
AgentA -> A2ASrv : POST /\n(application/json)
|
||||
A2ASrv -> AgentB : dispatch(message)
|
||||
AgentB --> A2ASrv : response
|
||||
A2ASrv --> AgentA : SSE / JSON-RPC
|
||||
|
||||
' --- Стрелки ---
|
||||
Orch -> SpawnAPI : calls
|
||||
SpawnAPI -> Sub : creates with custom config
|
||||
Sub -> SubBg : has its own
|
||||
Orch -> Orch : main flow continues
|
||||
Sub --> Orch : Flow<SubAgentEvent> emits\n(Started / ToolCalled / ToolResult /\nAssistantMessage / Done / Failed)
|
||||
|
||||
Orch -> A2ASrv : can also delegate to remote agent
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Sub-agents API
|
||||
|
||||
```kotlin
|
||||
sealed interface SubAgentEvent {
|
||||
data class Started(val taskId: String) : SubAgentEvent
|
||||
data class AssistantMessage(val text: String) : SubAgentEvent
|
||||
data class ToolCalled(val toolName: String, val args: JsonObject) : SubAgentEvent
|
||||
data class ToolResult(val toolName: String, val result: String) : SubAgentEvent
|
||||
data class Done(val taskId: String, val finalResult: String) : SubAgentEvent
|
||||
data class Failed(val taskId: String, val error: String) : SubAgentEvent
|
||||
}
|
||||
|
||||
interface BaseAgent {
|
||||
// ... existing methods ...
|
||||
|
||||
/**
|
||||
* Spawn дочерний агент с изолированным контекстом (memory, system prompt,
|
||||
* tools). Возвращает Flow событий жизненного цикла + результата.
|
||||
* Cancellation родителя НЕ отменяет sub-agent — sub-agent живёт до Done/Failed.
|
||||
*/
|
||||
fun spawnChild(config: SubAgentConfig): Flow<SubAgentEvent>
|
||||
}
|
||||
|
||||
data class SubAgentConfig(
|
||||
val systemPrompt: String,
|
||||
val tools: List<NamedTool> = emptyList(),
|
||||
val memory: MemorySystem = EmptyMemory(),
|
||||
val model: LiteLlm? = null, // если null — делит LLM родителя
|
||||
val maxTurns: Int = 10,
|
||||
val timeoutMs: Long = 60_000,
|
||||
)
|
||||
```
|
||||
|
||||
## Зачем изолированный scope
|
||||
|
||||
Sub-agent получает **свою копию контекста**, не делит memory с родителем. Это критично:
|
||||
- `research_subagent` — должен исследовать тему, не отвечать на основные сообщения пользователя
|
||||
- `summarize_subagent` — суммаризировать документ, не трогать основной диалог
|
||||
- `code_review_subagent` — ревьюить PR, не видеть разговор
|
||||
|
||||
Если нужно расшарить контекст — это explicit через `sharedMemory: SharedMemoryHandle` параметр, не default.
|
||||
|
||||
## A2A между независимыми агентами
|
||||
|
||||
Уже есть `:a2a-server` модуль (см. `standalone/build.gradle.kts` — `implementation(libs.a2a.server)`). Использовался для AG-UI/A2A протокола в `:standalone`. Можно переиспользовать для межагентного общения.
|
||||
|
||||
Сценарий: orchestrator-agent не может сам решить задачу → делегирует remote-агенту через A2A → получает response → продолжает. Это уже работающая инфраструктура.
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ Уже есть:
|
||||
- `:a2a-server` подключён
|
||||
- `BaseAgent.spawnChild` — **не существует**, но `ConversationLoop` уже умеет создавать изолированный scope через свой `agentScope` — нужна только обёртка
|
||||
|
||||
⏳ Не сделано:
|
||||
- `SubAgentConfig` + `Flow<SubAgentEvent>` API
|
||||
- `EmptyMemory` (null-object для изолированного scope)
|
||||
- Lifecycle management (parent dies → child должен complete or be cancelled?)
|
||||
- Сериализация sub-agent state для отладки (event log)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,171 @@
|
||||
# 05 — Android Agent Stack
|
||||
|
||||
Что меняется vs `:standalone`. Цель: `BaseAgent` тот же самый, но platform-impl разные (Storage, LLM, Vector Memory, MCP).
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml android-agent-stack
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Android Agent Stack — что меняется vs Standalone
|
||||
|
||||
' --- Android side ---
|
||||
package "Android Application" {
|
||||
[MainActivity\n(Compose)] as Activity
|
||||
[AndroidAgentRunner\n(workmanager / service)] as Runner
|
||||
[AndroidAgentBuilder] as AndroidBuilder
|
||||
}
|
||||
|
||||
package "Android-specific impls" {
|
||||
[StorageSqliteAndroid\n(Room/sqlite)] as StorageA
|
||||
[MemoryVectorAndroid\n(ONNX runtime + ANN)] as MemVecA
|
||||
[LitertAndroid\n(NNAPI delegate)] as LitertA
|
||||
[SoulFileAndroid\n(context.filesDir)] as SoulA
|
||||
[McpRegistry\nstdio: ProcessBuilder] as McpA
|
||||
}
|
||||
|
||||
' --- Shared (KMP) ---
|
||||
package "Agent Runtime (shared)" {
|
||||
[AgentCore\nBaseAgent] as AgentCore
|
||||
[AgentBuilder] as Builder
|
||||
}
|
||||
|
||||
package "Domain (shared)" {
|
||||
[LlmTools\ncommonMain] as LlmT
|
||||
[BackgroundEvents\ncommonMain] as Ev
|
||||
[McpBridge\njvmMain] as McpB
|
||||
[Skills\ncommonMain] as Skills
|
||||
}
|
||||
|
||||
package "Memory (shared impl)" {
|
||||
[MemoryMd\n(commonMain)] as MemMd
|
||||
[MemoryApi\ninterfaces] as MemApi
|
||||
}
|
||||
|
||||
' --- Зависимости ---
|
||||
Activity --> Runner
|
||||
Runner --> AndroidBuilder
|
||||
AndroidBuilder --> AgentCore
|
||||
|
||||
AndroidBuilder --> StorageA
|
||||
AndroidBuilder --> MemVecA
|
||||
AndroidBuilder --> LitertA
|
||||
AndroidBuilder --> SoulA
|
||||
AndroidBuilder --> McpA
|
||||
|
||||
AgentCore --> LlmT
|
||||
AgentCore --> Ev
|
||||
AgentCore --> McpB
|
||||
AgentCore --> Skills
|
||||
AgentCore --> MemMd
|
||||
|
||||
' --- Главные отличия от Standalone ---
|
||||
note right of LitertA
|
||||
On-device inference.
|
||||
LiteRT с NNAPI delegate →
|
||||
работает на CPU/GPU/NPU
|
||||
прямо на устройстве, без сети.
|
||||
|
||||
vs Standalone: HTTP-only
|
||||
(OpenAI-compatible).
|
||||
end note
|
||||
|
||||
note right of StorageA
|
||||
android.database.sqlite
|
||||
через Room или сырой API.
|
||||
|
||||
vs Standalone: JDBC +
|
||||
Sqlite-JDBC driver
|
||||
(только JVM).
|
||||
end note
|
||||
|
||||
note right of MemVecA
|
||||
JVector JVM-only. На Android
|
||||
нужна альтернатива —
|
||||
ONNX Runtime + какой-нибудь
|
||||
ANN (Annoy/HNSW).
|
||||
|
||||
Или пока без vector memory,
|
||||
только MemoryMd.
|
||||
end note
|
||||
|
||||
note right of McpA
|
||||
MCP через ProcessBuilder
|
||||
на Android работает, но
|
||||
subprocess lifecycle
|
||||
сложнее (foreground service
|
||||
нужен для долгого subprocess).
|
||||
end note
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Что общего с `:standalone`
|
||||
|
||||
**`BaseAgent`, `BackgroundScheduler`, `LlmTools`, `McpBridge`, `Skills`, `MemoryMd` — всё KMP (commonMain).** Android-agent = `:standalone` с другим wiring'ом. Не нужно переписывать agent logic.
|
||||
|
||||
## Что другое
|
||||
|
||||
| Компонент | `:standalone` (JVM) | `:agentik-android` (Android) | Сложность |
|
||||
|---|---|---|---|
|
||||
| Storage | `:storage-sqlite` (JDBC + Sqlite-JDBC) | `:storage-sqlite-android` (Room или raw) | Низкая — тот же `StorageBundle` interface |
|
||||
| LLM | `:litert-openai` (HTTP), `:litert-google` (LiteRT JVM) | `:litert-android` (LiteRT Android, NNAPI delegate) | Средняя — нужен новый модуль |
|
||||
| Vector memory | `:memory-vector` (JVector) | `:memory-vector-android` (ONNX Runtime + HNSW/Annoy) | Высокая — JVector JVM-only, нужна альтернатива |
|
||||
| SOUL provider | `FileSoulProvider` (path) | `SoulFileAndroid` (`context.filesDir`) | Низкая |
|
||||
| MCP | `McpRegistry` (ProcessBuilder, stdio subprocess) | Тот же `McpRegistry`, но subprocess в foreground service | Средняя — нужен Android service |
|
||||
| Embedding | `HttpEmbeddingClient` (HTTP) | Тот же ИЛИ on-device (ONNX) | Средняя |
|
||||
|
||||
## Минимальный Android agent (v1)
|
||||
|
||||
Если не нужны все фичи сразу — минимум:
|
||||
|
||||
```kotlin
|
||||
val agent = androidAgentBuilder(context) {
|
||||
llm(LitertAndroid.onDevice(context, modelPath = "/data/local/tmp/model.litertlm"))
|
||||
storage(SqliteStorage.android(context, "agent.db"))
|
||||
memory(MemoryMd.root(context.filesDir.resolve("memory")))
|
||||
soul(FileSoul(context.filesDir.resolve("SOUL.md")))
|
||||
background {
|
||||
// OnClosing + OnCompaction работают так же как на JVM
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Без MCP, без vector memory (только MemoryMd на файлах), только on-device LLM. Достаточно для off-line агента.
|
||||
|
||||
## Foreground service для MCP
|
||||
|
||||
Если нужны MCP-серверы (например, локальный file-system MCP) — subprocess нужен foreground service чтобы Android не убил его при выключении экрана. Это добавляет сложности:
|
||||
|
||||
```kotlin
|
||||
class McpForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
val proc = ProcessBuilder(command, args).start()
|
||||
// ... route stdio to McpLiteToolAdapter ...
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Пока можно без этого (только если MCP нужен на Android).
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ KMP-ready:
|
||||
- `:llm-tools` (commonMain, платформо-агностик)
|
||||
- `:mcp-bridge` (jvmMain — Android-вариант через `:mcp-bridge-android`)
|
||||
- `:skills` (commonMain)
|
||||
- `:memory-md` (commonMain)
|
||||
- `:proto` (commonMain)
|
||||
|
||||
⏳ Не существует:
|
||||
- `:storage-sqlite-android`
|
||||
- `:memory-vector-android`
|
||||
- `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `:agent-core` (выделить BaseAgent + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,64 @@
|
||||
# agentik — диаграммы архитектуры
|
||||
|
||||
PlantUML-схемы для обсуждения будущей структуры (Android agent, multi-user chat, sub-agents, A2A). Это **целевое состояние**, не текущее.
|
||||
|
||||
## Файлы
|
||||
|
||||
Каждый `.md` содержит:
|
||||
- Краткое описание (что показывает)
|
||||
- **Пред-рендеренный SVG** (``) — гарантированно показывается **везде**
|
||||
- PlantUML source в ` ```plantuml ` блоке — для редактирования (требует Graphviz `dot` для рендеринга)
|
||||
- Дополнительный markdown-текст (что нужно сделать, текущее vs целевое)
|
||||
|
||||
| Файл | Что показывает |
|
||||
|---|---|
|
||||
| [01-module-layers.md](./01-module-layers.md) | Целевая модульная структура (приложения → runtime → домен → абстракции → платформенные impl). Что в каком слое и кто от кого зависит. |
|
||||
| [02-agent-composition.md](./02-agent-composition.md) | Как `AgentBuilder` собирает `BaseAgent` из компонентов. Memory backend сам объявляет свои tools. BackgroundScheduler подписан на события (НЕ interval-poll). |
|
||||
| [03-multi-user-chat.md](./03-multi-user-chat.md) | Сценарий: чат с N пользователями, mention-detection, админ-команды, agent отвечает только когда addressed. |
|
||||
| [04-sub-agents.md](./04-sub-agents.md) | Orchestrator spawn'ит sub-agent с изолированным контекстом, получает `Flow<SubAgentEvent>`. A2A между независимыми агентами через `:a2a-server`. |
|
||||
| [05-android-stack.md](./05-android-stack.md) | Что меняется на Android: on-device LLM (NNAPI), Room/sqlite, ONNX-based vector memory, foreground-service для MCP subprocess. |
|
||||
|
||||
## Почему SVG + PlantUML source
|
||||
|
||||
PlantUML требует Java + (для component/class/deployment диаграмм) Graphviz `dot`. Если `dot` не установлен — рендерер падает с ошибкой "Executable dot does not exist".
|
||||
|
||||
Решение: **пре-рендерим в SVG один раз** и вставляем как `<img>`. Диаграмма гарантированно показывается в любом markdown-viewer (GitHub, IntelliJ, VSCode, GitLab) без зависимостей. PlantUML source в code block остаётся для редактирования.
|
||||
|
||||
## Как редактировать диаграмму
|
||||
|
||||
1. Меняешь PlantUML-source в ` ```plantuml ` блоке `.md` файла.
|
||||
2. Ре-рендеришь SVG:
|
||||
```bash
|
||||
mkdir -p /tmp/plantuml-work && chmod 777 /tmp/plantuml-work
|
||||
cp docs/diagrams/*.md /tmp/plantuml-work/
|
||||
docker run --rm -v /tmp/plantuml-work:/work plantuml/plantuml -tsvg /work/*.md
|
||||
cp /tmp/plantuml-work/*.svg docs/diagrams/
|
||||
```
|
||||
3. Проверяешь что SVG обновился:
|
||||
```bash
|
||||
ls -la docs/diagrams/*.svg
|
||||
```
|
||||
4. Коммитишь оба файла: `.md` (source) и `.svg` (rendered).
|
||||
|
||||
Требует Docker (или локального PlantUML+Graphviz). `apt install graphviz` для Arch/Manjaro.
|
||||
|
||||
## Контекст
|
||||
|
||||
Текущий код движется в эту сторону:
|
||||
- `:llm-tools` extracted ✅
|
||||
- `:mcp-bridge` extracted ✅
|
||||
- `BackgroundScheduler` стал event-driven ✅
|
||||
- `ConversationLoop` стал отдельным компонентом ✅
|
||||
|
||||
Не сделано (см. детали в каждом .md):
|
||||
- `:agent-core` (выделить `BaseAgent` + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
- `:storage-sqlite-android`, `:memory-vector-android`, `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `MentionDetector` interface + adapters для multi-user chat
|
||||
- `BaseAgent.spawnChild` + `Flow<SubAgentEvent>`
|
||||
|
||||
Подробнее:
|
||||
- `STANDALONE-REVIEW.md` — что плохо в текущем коде
|
||||
- `MEMORY-DESIGN.md` — детали memory архитектуры
|
||||
- `STANDALONE.md` — текущий standalone
|
||||
@@ -0,0 +1,137 @@
|
||||
# `:event-store` — bounded-tail event log (KMP)
|
||||
|
||||
## Что это
|
||||
|
||||
Двухуровневое хранилище событий агента. Этот модуль — **короткий
|
||||
bounded tail** для live-SSE и недавнего replay. Полный audit log
|
||||
живёт в `:message-store-api` (никогда не эвиктится, source of truth).
|
||||
|
||||
Три принципа:
|
||||
|
||||
1. **Tail управляет TTL сам.** Никаких `prune`/`cleanup` методов наружу —
|
||||
implementation решает, когда выкинуть старый event. Caller'ы не
|
||||
могут забыть cleanup.
|
||||
2. **Catchup + live в одном Flow.** `events(after)` сначала отдаёт
|
||||
буферизованный диапазон, потом переключается на live tail — клиент
|
||||
не должен знать, где у него "разрыв".
|
||||
3. **Read-only контракт для consumer'ов.** Запись через
|
||||
[MutableEventStore], чтение через [EventStore]. Compile-time
|
||||
гарантия что observer не сможет писать в store.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:standalone` ChatAgent — append через `MutableOutboxStore` (заменяет
|
||||
текущий `agentEvents: MutableSharedFlow` + `persistAgentEvent`).
|
||||
- `:server` Routes.kt — `/events/all` SSE endpoint читает через
|
||||
`EventStore.events(after)`.
|
||||
- Будущий `:android-agent` core — same интерфейс для локального
|
||||
bounded tail без dedicated server connection.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─ :event-store (этот модуль) ────────────────────────┐
|
||||
│ Bounded tail с auto-TTL: │
|
||||
│ • append(event) ← producer │
|
||||
│ • events(after): Flow ← consumer │
|
||||
│ • earliestEventDate() для gap detection │
|
||||
│ TTL/cap eviction — внутри impl │
|
||||
└───────────────────────────────────────────────────┘
|
||||
▲ gap detected
|
||||
│
|
||||
┌─ :message-store-api (полный audit log) ───────────┐
|
||||
│ MessageStore: query(after, before, limit) │
|
||||
│ Никогда не эвиктится. Source of truth. │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Reconnect pattern** (caller делает):
|
||||
|
||||
```kotlin
|
||||
val earliest = eventStore.earliestEventDate()
|
||||
if (client.lastSeen < earliest) {
|
||||
// gap: догоняем через :message-store-api
|
||||
val gap = messageStore.query(after = client.lastSeen, before = earliest)
|
||||
applyAll(gap)
|
||||
client.lastSeen = gap.last().createdAt
|
||||
}
|
||||
eventStore.events(after = client.lastSeen).collect { apply(it) }
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `OutboxStore` (read-only, для consumer'ов)
|
||||
|
||||
```kotlin
|
||||
interface EventStore : AutoCloseable {
|
||||
fun events(after: Instant?): Flow<CommonEvent>
|
||||
fun conversationEvents(
|
||||
after: Instant?,
|
||||
conversationId: String? = null, // null = все диалоги
|
||||
): Flow<CommonEvent.Conversation>
|
||||
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent>
|
||||
suspend fun earliestEventDate(): Instant // non-null: now() для пустого буфера
|
||||
override fun close()
|
||||
}
|
||||
```
|
||||
|
||||
**Семантика фильтров**:
|
||||
- `conversationEvents(null)` — все диалоги.
|
||||
- `conversationEvents("c-123")` — один конкретный диалог.
|
||||
- `agentEvents(...)` — только lifecycle (Created/Deleted/Renamed).
|
||||
|
||||
Все три возвращают **типизированные** subtype'ы [CommonEvent], так что
|
||||
caller'у не нужно `.filterIsInstance` на клиентской стороне.
|
||||
|
||||
### `MutableEventStore : EventStore` (для producer'ов)
|
||||
|
||||
```kotlin
|
||||
interface MutableEventStore : EventStore {
|
||||
suspend fun append(event: CommonEvent)
|
||||
}
|
||||
```
|
||||
|
||||
**Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
|
||||
retry даст дубликат. Для exactly-once — dedup через
|
||||
`:message-store-api` (там есть монотонный `id`).
|
||||
|
||||
**Silently evicted**: implementation может выкинуть event сразу после
|
||||
append по TTL/cap. Producer не должен полагаться на то, что event
|
||||
дойдёт до клиента, если он вне retention window.
|
||||
|
||||
## Когда использовать какой интерфейс
|
||||
|
||||
| Caller | Method |
|
||||
|---|---|
|
||||
| Server `/events/all` SSE (mixed) | `events(after)` |
|
||||
| Server `/conversations/{id}/events` SSE | `conversationEvents(after, conversationId)` |
|
||||
| Server `/agent/events` SSE (lifecycle only) | `agentEvents(after)` |
|
||||
| Admin dashboard (lifecycle) | `agentEvents(after)` |
|
||||
| Parent orchestrator (mixed) | `events(after)` |
|
||||
| Тесты | `events(after)` + projection через фильтр |
|
||||
|
||||
## Как добавить новый implementation
|
||||
|
||||
1. Создать класс с конструктором и lifecycle (`close()` обязан
|
||||
освободить ресурсы).
|
||||
2. Реализовать минимум: append (с TTL eviction), events (Flow с
|
||||
catchup + live), earliestEventDate (non-null Instant, now() если
|
||||
буфер пуст).
|
||||
3. Для persistent impl: SQL/ksqlite таблица с индексом по date,
|
||||
вставка = `INSERT OR IGNORE` для дедупликации на уровне БД
|
||||
(если в схеме будет id).
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
- ✅ Interface дизайн (`OutboxStore` + `MutableOutboxStore`)
|
||||
- ✅ KMP build (jvm + linuxX64 + mingwX64)
|
||||
- ⏳ Нет implementations (next: `InMemoryEventStore` для тестов)
|
||||
- ⏳ Не интегрирован в `:standalone`/`:server`
|
||||
|
||||
## Зависимости
|
||||
|
||||
- `:proto` (api) — тип `CommonEvent` (3 AgentEvent + 9 Conversation.Event вариантов).
|
||||
- `kotlinx-coroutines-core` (api) — `Flow`.
|
||||
|
||||
Никаких `kotlinx-serialization`, `kotlin-logging`, platform-specific
|
||||
зависимостей — этот модуль намеренно minimal.
|
||||
@@ -0,0 +1,32 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-интерфейс bounded-tail event log'а. Implementation-specific TTL/cap
|
||||
// eviction — caller's responsibility НЕ вызывать cleanup() (метод не существует).
|
||||
//
|
||||
// Тип [AllEvent] из :proto — typed envelope (3 AgentEvent + 9 Conversation.Event
|
||||
// вариантов). :event-store отвечает за bounded-tail с auto-TTL, но не за
|
||||
// сериализацию envelope'а — это делает :proto (уже @Serializable).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":proto"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,13 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
|
||||
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||
|
||||
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
|
||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
|
||||
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
|
||||
# (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
|
||||
# Используется из :memory-md-vector и :memory-vector напрямую через
|
||||
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
|
||||
# нужный variant под target).
|
||||
# `siglip` — JVM+Android only (onnx-runtime), подключается в jvmMain.
|
||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "text-embedding-kmp" }
|
||||
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
||||
|
||||
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||
|
||||
@@ -5,10 +5,6 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// Зеркалит набор :proto / :server / :memory-api — KMP-модуль с интерфейсами
|
||||
// хранилища и разговорной истории, без платформенного IO. Конкретные
|
||||
// реализации (sqlite, in-memory, android) живут в отдельных модулях.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
+4
-9
@@ -1,17 +1,12 @@
|
||||
package pw.binom.agentik.storage
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Часть контента сообщения на уровне хранилища.
|
||||
*
|
||||
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
|
||||
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
|
||||
* идентичны, но даёт возможность заменить transport-протокол без миграции
|
||||
* таблиц.
|
||||
*
|
||||
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
|
||||
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
|
||||
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
|
||||
* в `Mapping.kt` storage impl'ов.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface Content {
|
||||
@@ -0,0 +1,41 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Append-only audit log сообщений — read-only представление.
|
||||
*
|
||||
* Producer-операция [MutableJournalStore.append] находится на
|
||||
* [MutableJournalStore] — этот интерфейс только для чтения, чтобы
|
||||
* consumer'ы физически не могли писать в audit log.
|
||||
*
|
||||
* Никаких обновлений, никакого удаления (кроме каскадного вместе
|
||||
* с ConversationStore.delete).
|
||||
*/
|
||||
interface JournalStore : AutoCloseable {
|
||||
|
||||
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||
|
||||
/**
|
||||
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
||||
* (по странице через `list()` пока не получит короткую страницу). Для
|
||||
* in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть
|
||||
* на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip.
|
||||
*/
|
||||
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val page = list(conversationId, after, offset, pageSize)
|
||||
if (page.isEmpty()) return@flow
|
||||
for (rec in page) emit(rec)
|
||||
if (page.size < pageSize) return@flow
|
||||
offset += page.size
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Запись в таблице `message` (append-only audit).
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface MessageRecord {
|
||||
val id: String
|
||||
val conversationId: String
|
||||
val createdAt: Instant
|
||||
|
||||
@Serializable
|
||||
sealed interface Body : MessageRecord {
|
||||
val content: List<Content>
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("user")
|
||||
data class UserMessage(
|
||||
override val id: String,
|
||||
override val conversationId: String,
|
||||
override val content: List<Content>,
|
||||
override val createdAt: Instant,
|
||||
val context: MessageContext? = null,
|
||||
) : Body
|
||||
|
||||
@Serializable
|
||||
@SerialName("assistant")
|
||||
data class AssistantMessage(
|
||||
override val id: String,
|
||||
override val conversationId: String,
|
||||
override val content: List<Content>,
|
||||
override val createdAt: Instant,
|
||||
val tokens: TurnTokens? = null,
|
||||
) : Body
|
||||
|
||||
@Serializable
|
||||
@SerialName("tool_call")
|
||||
data class ToolCall(
|
||||
override val id: String,
|
||||
override val conversationId: String,
|
||||
val toolName: String,
|
||||
val toolTitle: String?,
|
||||
val toolArgsJson: String,
|
||||
override val createdAt: Instant,
|
||||
) : MessageRecord
|
||||
|
||||
@Serializable
|
||||
@SerialName("tool_result")
|
||||
data class ToolResult(
|
||||
override val id: String,
|
||||
override val conversationId: String,
|
||||
val toolCallId: String,
|
||||
val result: String?,
|
||||
override val createdAt: Instant,
|
||||
) : MessageRecord
|
||||
|
||||
@Serializable
|
||||
@SerialName("error")
|
||||
data class Error(
|
||||
override val id: String,
|
||||
override val conversationId: String,
|
||||
val message: String,
|
||||
val code: String?,
|
||||
override val createdAt: Instant,
|
||||
) : MessageRecord
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
/**
|
||||
* Mutable вариант [JournalStore] — добавляет producer-операцию [append].
|
||||
*
|
||||
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
|
||||
* ConversationLoop, ToolDispatcher, sub-agents, A2A-bridge).
|
||||
* Consumer'ы (DebugRoutes, admin dashboards, parent agents) должны
|
||||
* принимать **read-only** [JournalStore] — тогда невозможно случайно
|
||||
* записать в audit log из observer'а.
|
||||
*
|
||||
* **Append семантика**: см. KDoc [MessageStore.append][JournalStore] —
|
||||
* на этом интерфейсе (не дублируем).
|
||||
*/
|
||||
interface MutableJournalStore : JournalStore {
|
||||
suspend fun append(record: MessageRecord)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val bodyJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MessageBodyPayload(
|
||||
val content: List<Content>,
|
||||
@SerialName("context")
|
||||
val context: MessageContext? = null,
|
||||
val tokens: TurnTokens? = null,
|
||||
)
|
||||
|
||||
fun encodeBodyPayload(
|
||||
content: List<Content>,
|
||||
context: MessageContext? = null,
|
||||
tokens: TurnTokens? = null,
|
||||
): String = bodyJson.encodeToString(
|
||||
MessageBodyPayload.serializer(),
|
||||
MessageBodyPayload(content = content, context = context, tokens = tokens),
|
||||
)
|
||||
|
||||
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
|
||||
|
||||
data class BodyDecoded(
|
||||
val content: List<Content>,
|
||||
val context: MessageContext?,
|
||||
val tokens: TurnTokens? = null,
|
||||
)
|
||||
|
||||
private fun readPayload(json: String): BodyDecoded {
|
||||
return try {
|
||||
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
|
||||
BodyDecoded(p.content, p.context, p.tokens)
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
|
||||
BodyDecoded(arr, null, null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Token usage одного assistant turn'а.
|
||||
*/
|
||||
@Serializable
|
||||
data class TurnTokens(
|
||||
val input: Int,
|
||||
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" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-реализация [MutableJournalStore] на `MutableList` + `Mutex` — для
|
||||
// тестов, dev-режима, embedded-сценариев (Android core, CLI, in-process кэш
|
||||
// в клиенте) и как образец для своей реализации.
|
||||
//
|
||||
// `list` фильтрует по `conversationId`+`createdAt>after` и сортирует
|
||||
// по `createdAt ASC`. Paging — поверх отфильтрованного списка.
|
||||
//
|
||||
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":journal-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package pw.binom.agentik.journal.inmemory
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.journal.MutableJournalStore
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Простая in-memory [MutableJournalStore] для тестов, dev-режима и
|
||||
* клиентских in-process кэшей.
|
||||
*
|
||||
* **Thread-safety**: `Mutex` поверх `MutableList<MessageRecord>`. Для
|
||||
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
|
||||
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
|
||||
*
|
||||
* **Контракт `list`**: возвращает подмножество с
|
||||
* `conversationId == conversationId && createdAt > after`, отсортированное
|
||||
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка.
|
||||
*
|
||||
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
|
||||
* на сервере). [close] — no-op.
|
||||
*
|
||||
* Типичный кэш-паттерн в клиенте:
|
||||
* ```
|
||||
* val local = InMemoryJournalStore()
|
||||
* val remote = HttpJournalStore(httpClient, baseUrl)
|
||||
* // backfill + кэширование:
|
||||
* remote.listFlow(convId, Instant.DISTANT_PAST).collect { local.append(it) }
|
||||
* // после этого `local.list(convId, after, offset, limit)` отдаёт из кэша.
|
||||
* ```
|
||||
*/
|
||||
class InMemoryJournalStore : MutableJournalStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val records: MutableList<MessageRecord> = mutableListOf()
|
||||
|
||||
override suspend fun append(record: MessageRecord): Unit = mutex.withLock {
|
||||
records.add(record)
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
after: Instant,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
): List<MessageRecord> = mutex.withLock {
|
||||
records.asSequence()
|
||||
.filter { it.conversationId == conversationId && it.createdAt > after }
|
||||
.sortedBy { it.createdAt }
|
||||
.drop(offset)
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Сбросить кэш (например, когда диалог удалён). */
|
||||
suspend fun clear(): Unit = mutex.withLock {
|
||||
records.clear()
|
||||
}
|
||||
|
||||
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
||||
suspend fun size(): Int = mutex.withLock { records.size }
|
||||
|
||||
override fun close() {
|
||||
// no-op: lifecycle HttpClient'а — снаружи.
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package pw.binom.agentik.journal.inmemory
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class InMemoryJournalStoreTest {
|
||||
|
||||
private fun userMsg(id: String, convId: String, text: String, at: Instant) =
|
||||
MessageRecord.UserMessage(
|
||||
id = id,
|
||||
conversationId = convId,
|
||||
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
|
||||
createdAt = at,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `append then list returns records sorted by createdAt ASC`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "first", t0))
|
||||
store.append(userMsg("m2", "c1", "second", t0 + 1.seconds))
|
||||
store.append(userMsg("m3", "c1", "third", t0 + 2.seconds))
|
||||
|
||||
val all = store.list("c1", Instant.DISTANT_PAST, 0, 100)
|
||||
assertEquals(3, all.size)
|
||||
assertEquals(listOf("m1", "m2", "m3"), all.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list filters by conversationId`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "a", t0))
|
||||
store.append(userMsg("m2", "c2", "b", t0 + 1.seconds))
|
||||
store.append(userMsg("m3", "c1", "c", t0 + 2.seconds))
|
||||
|
||||
assertEquals(2, store.list("c1", Instant.DISTANT_PAST, 0, 100).size)
|
||||
assertEquals(1, store.list("c2", Instant.DISTANT_PAST, 0, 100).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list filters by after cursor`() = 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))
|
||||
|
||||
val afterT0 = store.list("c1", t0, 0, 100)
|
||||
assertEquals(listOf("m2", "m3"), afterT0.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list applies offset and limit`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
repeat(10) { i -> store.append(userMsg("m$i", "c1", "x", t0 + i.seconds)) }
|
||||
|
||||
val page = store.list("c1", Instant.DISTANT_PAST, offset = 3, limit = 4)
|
||||
assertEquals(listOf("m3", "m4", "m5", "m6"), page.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the cache`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "x", t0))
|
||||
assertEquals(1, store.size())
|
||||
store.clear()
|
||||
assertEquals(0, store.size())
|
||||
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// KMP-реализация :journal-api (JournalStore / MutableJournalStore) поверх ksqlite.
|
||||
// Минимальная — только таблица `message` для append-only audit log'а.
|
||||
// ConversationStore / ReflectionStore / WorkingMemoryStore живут в своих
|
||||
// собственных ksqlite-модулях.
|
||||
//
|
||||
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||
// на Linux (см. KDoc :storage-ksqlite).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT")
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
api(project(":journal-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.journal.MutableJournalStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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] не
|
||||
* переименовывался.
|
||||
*
|
||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||
* конструкторе и закрываются в [close]. Без этого 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(
|
||||
private val connection: SQLiteConnection,
|
||||
) : MutableJournalStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_MESSAGE}
|
||||
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
)
|
||||
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||
FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_CREATED_AT} > ?
|
||||
ORDER BY ${Schema.COL_CREATED_AT} ASC, ${Schema.COL_ID} ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||
)
|
||||
|
||||
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||
val (kind, payload) = encodeRecord(record)
|
||||
mutex.withLock {
|
||||
insertStmt.reset()
|
||||
insertStmt.clearBindings()
|
||||
insertStmt.bindText(1, record.id)
|
||||
insertStmt.bindText(2, record.conversationId)
|
||||
insertStmt.bindText(3, kind)
|
||||
insertStmt.bindText(4, payload)
|
||||
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
|
||||
insertStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
after: Instant,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
): List<MessageRecord> = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
listStmt.reset()
|
||||
listStmt.clearBindings()
|
||||
listStmt.bindText(1, conversationId)
|
||||
listStmt.bindLong(2, after.toEpochMilliseconds())
|
||||
listStmt.bindLong(3, limit.toLong())
|
||||
listStmt.bindLong(4, offset.toLong())
|
||||
val out = mutableListOf<MessageRecord>()
|
||||
listStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) out.add(rs.toMessageRecord(json))
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
clearStmt.reset()
|
||||
clearStmt.clearBindings()
|
||||
clearStmt.bindText(1, conversationId)
|
||||
clearStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
insertStmt.close()
|
||||
listStmt.close()
|
||||
clearStmt.close()
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.journal.decodeBodyPayload
|
||||
import pw.binom.agentik.journal.encodeBodyPayload
|
||||
import pw.binom.db.ksqlite.SQLiteResultSet
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
|
||||
*
|
||||
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers
|
||||
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
|
||||
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
|
||||
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
|
||||
*/
|
||||
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
||||
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
|
||||
content = record.content,
|
||||
context = record.context,
|
||||
)
|
||||
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
|
||||
content = record.content,
|
||||
tokens = record.tokens,
|
||||
)
|
||||
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
||||
CallPayload.serializer(),
|
||||
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
|
||||
)
|
||||
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
|
||||
ResultPayload.serializer(),
|
||||
ResultPayload(toolCallId = record.toolCallId, result = record.result),
|
||||
)
|
||||
is MessageRecord.Error -> "error" to Json.encodeToString(
|
||||
ErrorPayload.serializer(),
|
||||
ErrorPayload(message = record.message, code = record.code),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
val id = getText(0)!!
|
||||
val convId = getText(1)!!
|
||||
val kind = getText(2)!!
|
||||
val payload = getText(3)!!
|
||||
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
||||
return when (kind) {
|
||||
"user" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
|
||||
}
|
||||
"assistant" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
|
||||
}
|
||||
"tool_call" -> {
|
||||
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
|
||||
}
|
||||
"tool_result" -> {
|
||||
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, result = p.result, createdAt = createdAt)
|
||||
}
|
||||
"error" -> {
|
||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
|
||||
}
|
||||
else -> error("Unknown message kind in audit log: $kind")
|
||||
}
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ResultPayload(val toolCallId: String, val result: String?)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ErrorPayload(val message: String, val code: String?)
|
||||
@@ -0,0 +1,91 @@
|
||||
package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
|
||||
*
|
||||
* Минимум — только то, что относится к `message` (append-only audit log).
|
||||
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
|
||||
* живут в других ksqlite-модулях.
|
||||
*
|
||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||
*/
|
||||
internal object Schema {
|
||||
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
|
||||
// ───── Таблица ─────
|
||||
const val TABLE_MESSAGE = "message"
|
||||
|
||||
// ───── Колонки ─────
|
||||
const val COL_ID = "id"
|
||||
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_MSG_CONV = "idx_msg_conv"
|
||||
|
||||
private val v1Ddl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||
$COL_KIND TEXT NOT NULL,
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL
|
||||
);
|
||||
""".trimIndent()
|
||||
|
||||
private val v1IndexesDdl = """
|
||||
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` оставит БД на предыдущей версии.
|
||||
*
|
||||
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||
*/
|
||||
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("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")
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
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.journal.MessageRecord
|
||||
import pw.binom.agentik.journal.TurnTokens
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
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.
|
||||
*/
|
||||
class KsqliteJournalStoreTest {
|
||||
|
||||
private lateinit var conn: SQLiteConnection
|
||||
private lateinit var store: KsqliteJournalStore
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
|
||||
Schema.migrate(conn)
|
||||
store = KsqliteJournalStore(conn)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
store.close()
|
||||
conn.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppendUserAndRetrieve() = runTest {
|
||||
store.append(MessageRecord.UserMessage(
|
||||
id = "m1",
|
||||
conversationId = "conv1",
|
||||
content = listOf(Content.Text("hello")),
|
||||
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||
context = null,
|
||||
))
|
||||
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||
assertEquals(1, list.size)
|
||||
val msg = list[0]
|
||||
assertEquals("m1", msg.id)
|
||||
assertEquals(MessageRecord.UserMessage::class, msg::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppendAssistantWithTokens() = runTest {
|
||||
store.append(MessageRecord.AssistantMessage(
|
||||
id = "m1",
|
||||
conversationId = "conv1",
|
||||
content = listOf(Content.Text("hi")),
|
||||
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||
tokens = TurnTokens(input = 50, output = 30),
|
||||
))
|
||||
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||
assertEquals(1, list.size)
|
||||
val msg = list[0] as MessageRecord.AssistantMessage
|
||||
assertEquals(TurnTokens(input = 50, output = 30), msg.tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListAfterFiltersByTimestamp() = 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))
|
||||
|
||||
val after = store.list("conv1", after = t1, offset = 0, limit = 10)
|
||||
assertEquals(2, after.size)
|
||||
assertEquals(listOf("m2", "m3"), after.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListFlowReturnsAllInOrder() = runTest {
|
||||
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||
for (i in 1..3) store.append(
|
||||
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
|
||||
)
|
||||
assertEquals(listOf("m1", "m2", "m3"), store.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearRemovesByConversation() = runTest {
|
||||
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), Instant.parse("2026-09-15T10:00:00Z"), null))
|
||||
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), Instant.parse("2026-09-15T10:00:00Z"), null))
|
||||
store.clear("conv1")
|
||||
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
|
||||
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
|
||||
// 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'у.
|
||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":memory-api"))
|
||||
api(project(":journal-api"))
|
||||
api(project(":reflection-api"))
|
||||
api(project(":context-api"))
|
||||
api(project(":skills"))
|
||||
api(libs.litert.api)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.litert.LiteConversation
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||
import pw.binom.agentik.memory.NewMemoryNote
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
import pw.binom.agentik.storage.Ids
|
||||
import pw.binom.agentik.journal.Ids
|
||||
import pw.binom.litert.LiteLlm
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.agentik.storage.Ids
|
||||
import pw.binom.agentik.storage.Reflection
|
||||
import pw.binom.agentik.journal.Ids
|
||||
import pw.binom.agentik.reflection.Reflection
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
@@ -27,7 +27,7 @@ import kotlin.time.Clock
|
||||
*/
|
||||
class LlmReflector(
|
||||
private val llm: LiteLlm,
|
||||
private val maxTurns: Int = 6,
|
||||
val maxTurns: Int = 6,
|
||||
private val maxTokens: Int = 512,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val clock: Clock = Clock.System,
|
||||
@@ -57,7 +57,7 @@ class LlmReflector(
|
||||
val parsed = ReflectionParser.parse(raw)
|
||||
?: return@withContext null
|
||||
Reflection(
|
||||
id = Ids.reflection(),
|
||||
id = pw.binom.agentik.reflection.Ids.new(),
|
||||
conversationId = null, // будет проставлен caller'ом ChatConversation
|
||||
createdAt = clock.now(),
|
||||
turnsAnalyzed = turns.size,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
/**
|
||||
* Минимальный парсер JSON-ответа от [LlmReflector].
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generic MCP (Model Context Protocol) bridge — переиспользуемый модуль,
|
||||
// который превращает любой MCP-сервер (stdio subprocess или HTTP endpoint)
|
||||
// в набор [LiteTool]-адаптеров.
|
||||
//
|
||||
// Вынесен из :standalone — MCP не специфичен для standalone'а, это generic
|
||||
// мост между MCP-SDK и litert-kmp. Может переиспользоваться в :agentik-cli
|
||||
// или :agentik-tui когда те снова включатся.
|
||||
//
|
||||
// Зависимости:
|
||||
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
|
||||
// - litert.api для LiteTool контракта
|
||||
// - MCP SDK (JVM-only)
|
||||
// - Ktor client (для StreamableHttpClientTransport)
|
||||
// - kotlinx-serialization для парсинга конфига
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":agent-toolsets"))
|
||||
|
||||
api(libs.litert.api)
|
||||
|
||||
implementation(libs.mcp.sdk.client)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
import mu.KotlinLogging
|
||||
|
||||
@@ -30,9 +30,9 @@ import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.standalone.agent.NamedTool
|
||||
import pw.binom.litert.LiteTool
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
/**
|
||||
* Реестр подключённых MCP-серверов.
|
||||
@@ -21,6 +21,13 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
// `TextEmbeddingExecutor` (suspend-обёртка над `TextEmbeddingExtractor`)
|
||||
// живёт в :memory-api с 2026-09-21 — раньше был `EmbeddingProvider` в
|
||||
// :memory-vector, но он JVM-only и блокировал :memory-md-vector от
|
||||
// нативных таргетов. text-embedding-kmp:api собирается под jvm+android+
|
||||
// linux/macos/ios/mingw (мы добавили нативные цели в их :api модуле),
|
||||
// так что KMP-потребители могут зависеть от него напрямую.
|
||||
api(libs.text.embedding.api)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
|
||||
@@ -24,4 +24,11 @@ data class MemoryNote(
|
||||
val useCount: Int = 0,
|
||||
val conversationId: String? = null,
|
||||
val source: MemorySource,
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Дешёвый content-fingerprint: хэш от id + content.
|
||||
* Используется vector-кэшами (`:memory-md-vector`, `:memory-vector`) для
|
||||
* определения "изменилась ли заметка" без re-embed'а.
|
||||
*/
|
||||
fun contentHash(): String = (id.hashCode().toLong() xor content.hashCode().toLong()).toString(16)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ kotlin {
|
||||
api(project(":memory-api"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
// `pw.binom.ai.embeddingtext:api` (TextEmbeddingExtractor + TextEmbedding)
|
||||
// теперь KMP с нативом (linuxX64/mingwX64/macOS/ios); тянем в commonMain.
|
||||
// Реализации (`siglip`, `http`) — JVM+Android only, см. jvmMain ниже.
|
||||
api(libs.text.embedding.api)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
@@ -34,14 +38,11 @@ kotlin {
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.jvector)
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
// Конкретная реализация TextEmbeddingExtractor поверх ONNX.
|
||||
implementation(libs.text.embedding.siglip)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
add("jvmMainApi", libs.text.embedding.api)
|
||||
add("jvmMainImplementation", libs.text.embedding.siglip)
|
||||
}
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package pw.binom.agentik.memory.vector
|
||||
|
||||
/**
|
||||
* Провайдер эмбеддингов: превращает текст в FloatArray фиксированной размерности.
|
||||
*
|
||||
* Реализация по умолчанию — HTTP-вызов `POST /v1/embeddings` к OpenAI-совместимому
|
||||
* API (OpenAI / litellm-proxy / vllm). С LRU-кэшом, чтобы не ходить в сеть
|
||||
* на каждый search/upsert.
|
||||
*/
|
||||
interface EmbeddingProvider {
|
||||
val dimension: Int
|
||||
suspend fun embed(text: String): FloatArray
|
||||
|
||||
/** Batch-вариант. По умолчанию — последовательный вызов [embed]. */
|
||||
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
||||
texts.map { embed(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Детерминированный провайдер для тестов: хеширует текст в псевдо-вектор.
|
||||
* Используется только в commonTest; в продакшн заменяется на HttpEmbeddingProvider.
|
||||
*/
|
||||
class FakeEmbeddingProvider(override val dimension: Int = 32) : EmbeddingProvider {
|
||||
override suspend fun embed(text: String): FloatArray {
|
||||
val v = FloatArray(dimension)
|
||||
// Простейший детерминированный seed — сумма char'ов по модулю.
|
||||
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
||||
for (i in 0 until dimension) {
|
||||
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||
}
|
||||
// L2-normalize чтобы cosine работал осмысленно.
|
||||
var norm = 0f
|
||||
for (x in v) norm += x * x
|
||||
norm = kotlin.math.sqrt(norm)
|
||||
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||
return v
|
||||
}
|
||||
}
|
||||
+26
-55
@@ -1,63 +1,34 @@
|
||||
package pw.binom.agentik.memory.vector
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
import pw.binom.agentik.memory.MemoryVectorIndex as KmpMemoryVectorIndex
|
||||
import pw.binom.agentik.memory.ScoredVector as KmpScoredVector
|
||||
|
||||
/**
|
||||
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1].
|
||||
* Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||
*/
|
||||
data class ScoredVector(
|
||||
val id: String,
|
||||
val score: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
|
||||
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
|
||||
* JVM-only alias на KMP-контракт из `:memory-api`. Удалять нельзя — пока
|
||||
* `:memory-vector` существует как JVM-only модуль с JVector-имплементацией,
|
||||
* все его internal helper'ы продолжают импортировать `MemoryVectorIndex` из
|
||||
* `pw.binom.agentik.memory.vector.*` (старое FQN). После удаления модуля —
|
||||
* можно убрать этот файл и переименовать пакеты импортов.
|
||||
*
|
||||
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это
|
||||
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||
* Раньше жил прямо здесь (`MemoryVectorIndex` + `ScoredVector` в
|
||||
* `:memory-vector/commonMain`), но переехал в `:memory-api` 2026-09-21
|
||||
* чтобы стать доступным из KMP-модуля `:memory-md-vector`.
|
||||
*/
|
||||
interface MemoryVectorIndex : AutoCloseable {
|
||||
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||
val dimension: Int
|
||||
|
||||
/** Количество записей в индексе. */
|
||||
suspend fun size(): Long
|
||||
@Deprecated(
|
||||
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||
replaceWith = ReplaceWith(
|
||||
"MemoryVectorIndex",
|
||||
"pw.binom.agentik.memory.MemoryVectorIndex",
|
||||
),
|
||||
)
|
||||
typealias MemoryVectorIndex = KmpMemoryVectorIndex
|
||||
|
||||
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
|
||||
suspend fun add(id: String, embedding: FloatArray)
|
||||
|
||||
/** Удалить запись по [id]. Возвращает true если запись была. */
|
||||
suspend fun remove(id: String): Boolean
|
||||
|
||||
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id (например, по категории). */
|
||||
suspend fun search(
|
||||
query: FloatArray,
|
||||
k: Int,
|
||||
filter: (MemoryNote) -> Boolean = { true },
|
||||
): List<ScoredVector>
|
||||
|
||||
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
|
||||
suspend fun flush()
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Доп. контекст для vector-индекса: фильтр по категории и conversationId
|
||||
* передаётся через замыкание, которое получает [MemoryNote]. Так [MemoryStore]
|
||||
* остаётся единственным источником правды по метаданным.
|
||||
*/
|
||||
fun noteMatches(
|
||||
note: MemoryNote,
|
||||
category: MemoryCategory? = null,
|
||||
conversationId: String? = null,
|
||||
): Boolean {
|
||||
if (category != null && note.category != category) return false
|
||||
if (conversationId != null && note.conversationId != conversationId) return false
|
||||
return true
|
||||
}
|
||||
@Deprecated(
|
||||
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||
replaceWith = ReplaceWith(
|
||||
"ScoredVector",
|
||||
"pw.binom.agentik.memory.ScoredVector",
|
||||
),
|
||||
)
|
||||
typealias ScoredVector = KmpScoredVector
|
||||
|
||||
+8
-6
@@ -6,6 +6,8 @@ import pw.binom.agentik.memory.MemorySearchQuery
|
||||
import pw.binom.agentik.memory.MemorySearchResult
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
import pw.binom.agentik.memory.noteMatches
|
||||
import kotlin.math.exp
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -23,13 +25,13 @@ import kotlinx.coroutines.sync.withLock
|
||||
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
|
||||
* потом re-rank по recency.
|
||||
*
|
||||
* [embeddingProvider] обязателен — используется для эмбеддинга контента при
|
||||
* [embedding] обязателен — используется для эмбеддинга контента при
|
||||
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
|
||||
*/
|
||||
class VectorMemoryStore(
|
||||
private val index: MemoryVectorIndex,
|
||||
private val metaStore: MemoryMetaStore,
|
||||
private val embeddingProvider: EmbeddingProvider,
|
||||
private val embedding: TextEmbeddingExecutor,
|
||||
) : MemoryStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
@@ -37,9 +39,9 @@ class VectorMemoryStore(
|
||||
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
|
||||
|
||||
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
|
||||
val embedding = embeddingProvider.embed(note.content)
|
||||
metaStore.put(note, embedding)
|
||||
index.add(note.id, embedding)
|
||||
val vec = embedding.embed(note.content)
|
||||
metaStore.put(note, vec)
|
||||
index.add(note.id, vec)
|
||||
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ class VectorMemoryStore(
|
||||
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
|
||||
|
||||
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
|
||||
val queryEmbedding = embeddingProvider.embed(query.query)
|
||||
val queryEmbedding = embedding.embed(query.query)
|
||||
val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
|
||||
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
|
||||
// через [metaStore.get] + [noteMatches] отрежет лишних.
|
||||
|
||||
+7
-5
@@ -9,6 +9,7 @@ import pw.binom.agentik.memory.MemorySearchQuery
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemorySystem
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
|
||||
/**
|
||||
* Бандл компонентов vector-бэкенда памяти — то же, что
|
||||
@@ -36,19 +37,20 @@ class VectorMemorySystem(
|
||||
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
|
||||
*
|
||||
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
|
||||
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient
|
||||
* @param embedding [TextEmbeddingExecutor] — обычно HttpEmbeddingClient.asExecutor()
|
||||
* @param topK размер top-K для prefetch
|
||||
*/
|
||||
fun open(
|
||||
dbPath: String,
|
||||
embedding: EmbeddingProvider,
|
||||
embedding: TextEmbeddingExecutor,
|
||||
topK: Int = 10,
|
||||
): VectorMemorySystem {
|
||||
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension)
|
||||
val dim = embedding.dimension
|
||||
val metaStore = SqliteMemoryMetaStore.open(dbPath, dim)
|
||||
// Граф пересобирается из SQLite (источник правды): без seed'ов
|
||||
// после рестарта in-RAM индекс пуст и search возвращал бы [],
|
||||
// пока не появятся новые upsert'ы.
|
||||
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries())
|
||||
val index = JVectorMemoryIndex(dim, metaStore.allEntries())
|
||||
val store = VectorMemoryStore(index, metaStore, embedding)
|
||||
val prefetcher = VectorPrefetcher(store, topK)
|
||||
val reviewer = VectorMemoryReviewer(store)
|
||||
@@ -59,7 +61,7 @@ class VectorMemorySystem(
|
||||
closables = listOfNotNull(
|
||||
metaStore,
|
||||
index,
|
||||
embedding as? AutoCloseable,
|
||||
embedding,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+15
-11
@@ -5,25 +5,26 @@ import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
|
||||
/**
|
||||
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
|
||||
* Используется при memory-backend=vector.
|
||||
*
|
||||
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов
|
||||
* к API на одинаковых промптах.
|
||||
* Реализует [TextEmbeddingExtractor] (из text-embedding-kmp:api) + оборачивается
|
||||
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||
* VectorMemoryStore. LRU-кэш на [cacheSize] текстов (default 256) — дедупликация
|
||||
* запросов к API на одинаковых промптах.
|
||||
*
|
||||
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
|
||||
* @param apiKey bearer-токен
|
||||
@@ -35,9 +36,9 @@ class HttpEmbeddingClient(
|
||||
private val apiUrl: String,
|
||||
private val apiKey: String,
|
||||
private val model: String,
|
||||
override val dimension: Int,
|
||||
private val dimension: Int,
|
||||
cacheSize: Int = 256,
|
||||
) : EmbeddingProvider, AutoCloseable {
|
||||
) : TextEmbeddingExtractor {
|
||||
|
||||
private val cache = LruCache<String, FloatArray>(cacheSize)
|
||||
private val http: HttpClient = HttpClient.newBuilder()
|
||||
@@ -45,11 +46,11 @@ class HttpEmbeddingClient(
|
||||
.build()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun embed(text: String): FloatArray {
|
||||
cache.get(text)?.let { return it }
|
||||
override fun embed(text: String): TextEmbedding {
|
||||
cache.get(text)?.let { return TextEmbedding(it) }
|
||||
val vector = fetchEmbedding(text)
|
||||
cache.put(text, vector)
|
||||
return vector
|
||||
return TextEmbedding(vector)
|
||||
}
|
||||
|
||||
private fun fetchEmbedding(text: String): FloatArray {
|
||||
@@ -83,6 +84,9 @@ class HttpEmbeddingClient(
|
||||
}
|
||||
|
||||
override fun close() = http.close()
|
||||
|
||||
/** Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью. */
|
||||
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = dimension)
|
||||
}
|
||||
|
||||
private class LruCache<K, V>(private val capacity: Int) {
|
||||
|
||||
+22
-26
@@ -1,25 +1,22 @@
|
||||
package pw.binom.agentik.memory.vector.embedding
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||
|
||||
/**
|
||||
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
|
||||
*
|
||||
* Особенности:
|
||||
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU),
|
||||
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать
|
||||
* доступ из нескольких корутин (ONNX-сессия не reentrant).
|
||||
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр
|
||||
* `dimension` в конструкторе не принимаем — берём через [probeDimension].
|
||||
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на
|
||||
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется —
|
||||
* легко добавить.
|
||||
* Реализует [TextEmbeddingExtractor] напрямую (делегирует в
|
||||
* `createSiglip2TextExtractor` из text-embedding-kmp:siglip) + оборачивается
|
||||
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||
* VectorMemoryStore. Сиглизация через `Dispatchers.IO` теперь внутри
|
||||
* `TextEmbeddingExecutor.embed` — раньше лежала здесь.
|
||||
*
|
||||
* Размерность фиксирована extractor'ом (SigLIP2-base = 768); передаём
|
||||
* явно в [asExecutor].
|
||||
*
|
||||
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
|
||||
* Скачать: см. README репы `text-embedding-kmp`.
|
||||
@@ -27,23 +24,22 @@ import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||
class SiglipEmbeddingProvider(
|
||||
modelPath: String,
|
||||
tokenizerPath: String,
|
||||
) : EmbeddingProvider, AutoCloseable {
|
||||
) : TextEmbeddingExtractor {
|
||||
|
||||
private val extractor: TextEmbeddingExtractor =
|
||||
private val delegate: TextEmbeddingExtractor =
|
||||
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
||||
|
||||
override val dimension: Int = run {
|
||||
val probe = extractor.embed("probe")
|
||||
probe.dim
|
||||
}
|
||||
override fun embed(text: String): TextEmbedding = delegate.embed(text)
|
||||
|
||||
private val mutex = Mutex()
|
||||
override fun close() = delegate.close()
|
||||
|
||||
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) {
|
||||
mutex.withLock { extractor.embed(text).values }
|
||||
}
|
||||
/**
|
||||
* Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью 768
|
||||
* (SigLIP2-base). Сигнатура стабильна — extractor всегда возвращает 768-dim.
|
||||
*/
|
||||
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = SIGLIP2_DIM)
|
||||
|
||||
override fun close() {
|
||||
extractor.close()
|
||||
companion object {
|
||||
const val SIGLIP2_DIM: Int = 768
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -32,7 +32,7 @@ class VectorMemoryStoreTest {
|
||||
// Загружаем начальные entries из metaStore (на случай если что-то там есть).
|
||||
val seedEntries = metaStore.allEntries()
|
||||
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim))
|
||||
store = VectorMemoryStore(index, metaStore, fakeEmbeddingExecutor(dimension = dim))
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
@@ -127,7 +127,7 @@ class VectorMemoryStoreTest {
|
||||
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
||||
val seedEntries = meta2.allEntries()
|
||||
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim))
|
||||
val store2 = VectorMemoryStore(idx2, meta2, fakeEmbeddingExecutor(dimension = dim))
|
||||
try {
|
||||
assertEquals(2L, idx2.size())
|
||||
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
|
||||
@@ -141,11 +141,11 @@ class VectorMemoryStoreTest {
|
||||
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
|
||||
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
|
||||
// из SQLite — иначе после рестарта search возвращает [] до первого upsert.
|
||||
val first = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
||||
val first = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
|
||||
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
|
||||
first.close()
|
||||
|
||||
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
||||
val second = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
|
||||
try {
|
||||
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
|
||||
assertTrue(results.any { it.note.id == "r" })
|
||||
|
||||
+11
-5
@@ -17,17 +17,18 @@ import kotlin.test.assertTrue
|
||||
class SiglipEmbeddingProviderTest {
|
||||
|
||||
@Test
|
||||
fun `dimension is 768 when model loads successfully`() {
|
||||
fun `dimension is 768 when model loads successfully`() = runBlocking {
|
||||
val modelDir = File("/tmp/text-emb-model")
|
||||
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
|
||||
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
|
||||
}
|
||||
SiglipEmbeddingProvider(
|
||||
val provider = SiglipEmbeddingProvider(
|
||||
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
|
||||
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
|
||||
).use { provider ->
|
||||
).asExecutor()
|
||||
provider.use {
|
||||
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
|
||||
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") }
|
||||
val v = provider.embed("hello world")
|
||||
assertEquals(768, v.size)
|
||||
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
|
||||
}
|
||||
@@ -41,7 +42,7 @@ class SiglipEmbeddingProviderTest {
|
||||
SiglipEmbeddingProvider(
|
||||
modelPath = nonExistent.absolutePath,
|
||||
tokenizerPath = nonExistent.absolutePath,
|
||||
).use { it.dimension }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +50,8 @@ class SiglipEmbeddingProviderTest {
|
||||
org.junit.Assume.assumeTrue(message(), condition)
|
||||
}
|
||||
}
|
||||
|
||||
// runBlocking нужен потому что suspend-вызов provider.embed в suspend-тесте.
|
||||
// Локальный импорт чтобы не тащить runBlocking в прод-код.
|
||||
private fun <T> runBlocking(block: suspend () -> T): T =
|
||||
kotlinx.coroutines.runBlocking { block() }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
// typealias-обёртки указывают на :journal-api — без него
|
||||
// компиляция падает на Unresolved reference 'journal'.
|
||||
api(project(":journal-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
// Нужен для @Serializable на AgentEvent/CommonEvent/Event — все
|
||||
// три типа теперь живут в :outbox-api (см. миграцию из :proto).
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены
|
||||
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization).
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
|
||||
*
|
||||
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
|
||||
* sub-agents, A2A-bridge). Consumer'ы (server SSE endpoints, admin
|
||||
* dashboards, parent agents) должны принимать **read-only** [OutboxStore]
|
||||
* — тогда невозможно случайно писать в store из observer'а.
|
||||
*
|
||||
* Типичное использование:
|
||||
* ```
|
||||
* // Producer
|
||||
* class ChatAgent(private val events: MutableEventStore) {
|
||||
* suspend fun doSomething() {
|
||||
* events.append(CommonEvent.Agent(date = now, event = AgentEvent.Created(...)))
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Consumer
|
||||
* class EventStreamEndpoint(private val events: EventStore) {
|
||||
* fun stream() = events.events(after = null)
|
||||
* // Ошибка компиляции если раскомментировать:
|
||||
* // events.append(...) // ← нельзя, MutableEventStore нет в типе
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
|
||||
* поэтому retry с тем же logical event (например, после network failure
|
||||
* между producer и store) приведёт к дубликату в tail'е. Это OK для
|
||||
* use case'a bounded-tail — клиент, делающий catchup через [events](after),
|
||||
* получит свой диапазон ровно один раз при подключении, а последующие
|
||||
* retry producer'а просто насытят tail повторами, не задевая уже
|
||||
* обработанные. Для гарантированной exactly-once — dedup через
|
||||
* [message-store] (там есть монотонный `id`).
|
||||
*
|
||||
* **Silently evicted**: implementation может выкинуть этот event сразу
|
||||
* после append (TTL/cap) без уведомления producer'а. Producer **не
|
||||
* должен** полагаться на то, что event дойдёт до клиента, если он
|
||||
* вне retention window.
|
||||
*/
|
||||
interface MutableOutboxStore : OutboxStore {
|
||||
/**
|
||||
* Положить event в log.
|
||||
*
|
||||
* - **Не идемпотентно** — см. KDoc интерфейса.
|
||||
* - **Suspend** для KMP I/O impl'ов (SQLite через JNI).
|
||||
*/
|
||||
suspend fun append(event: CommonEvent)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Bounded-tail event log с автоматическим управлением TTL.
|
||||
*
|
||||
* **Архитектура двухуровневого хранилища событий**:
|
||||
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
|
||||
* События автоматически эвиктятся по TTL/cap (implementation-defined).
|
||||
* 2. **Message store (`:message-store-api`)** = полный audit log, никогда не
|
||||
* эвиктится. Source of truth для всего прошлого.
|
||||
*
|
||||
* **Паттерн reconnect** (caller'ы):
|
||||
* ```
|
||||
* val earliest = store.earliestEventDate()
|
||||
* if (client.lastSeen < earliest) {
|
||||
* // gap обнаружен — идём в message store за прошлым
|
||||
* val gap = messageStore.query(after = client.lastSeen, before = earliest)
|
||||
* applyAll(gap)
|
||||
* }
|
||||
* store.events(after = client.lastSeen).collect { apply(it) }
|
||||
* ```
|
||||
*
|
||||
* **Нет delete/cleanup методов** — TTL/cap eviction полностью на стороне
|
||||
* implementation. Это:
|
||||
* - Убирает single source of truth дублирование (caller не может забыть cleanup).
|
||||
* - Позволяет impl выбирать retention strategy (TTL, size cap, sliding window).
|
||||
* - Сохраняет контракт clean: интерфейс только о put/get.
|
||||
*
|
||||
* **Read-only**: этот интерфейс предоставляет только read-операции.
|
||||
* Для записи см. [MutableOutboxStore].
|
||||
*
|
||||
* **Подписки нереентрантные**: каждый вызов [events] создаёт **новую
|
||||
* подписку** (cold Flow). Один [events] НЕ видит события, добавленные до
|
||||
* его вызова, если [after] == null. Если нужен catchup — передавайте
|
||||
* `after = lastSeenDate` явно.
|
||||
*
|
||||
* **Multi-consumer**: разные [events] подписки видят одно и то же live
|
||||
* tail. Каждая подписка — независимая projection.
|
||||
*/
|
||||
interface OutboxStore : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Subscribe на events.
|
||||
*
|
||||
* **`after == null`** → только **live** (события с момента вызова
|
||||
* `events()`). Каждое новое событие от любого producer'а немедленно
|
||||
* появится в Flow. Буфер replay не отдаётся.
|
||||
*
|
||||
* **`after != null`** → сначала **catchup**: эмитт все буферизованные
|
||||
* события с `date > after`, порядок `date ASC` (ties по `id ASC`).
|
||||
* Затем **live** (как null-case).
|
||||
*
|
||||
* Cold Flow: каждый вызов — новая подписка. Вызов **после** append'а
|
||||
* не увидит этот конкретный event (если `after == null`); для catchup
|
||||
* передавайте явный `after`.
|
||||
*
|
||||
* ВАЖНО: `Flow` НЕ бросает ошибку при потере сети между producer и
|
||||
* store — такие события просто не дойдут до этого Flow. Для гарантии
|
||||
* полноты клиент обязан cross-check с [earliestEventDate] и fallback
|
||||
* в message store при gap'е (см. KDoc интерфейса).
|
||||
*/
|
||||
fun events(after: Instant?): Flow<CommonEvent>
|
||||
|
||||
/**
|
||||
* Subscribe на **только conversation events** (т.е. [CommonEvent.Conversation]).
|
||||
*
|
||||
* - [conversationId] == null → события **всех** диалогов.
|
||||
* - [conversationId] != null → события **только этого** диалога.
|
||||
*
|
||||
* Семантика `after` идентична [events] (catchup + live).
|
||||
* Возвращаемый тип — конкретный subtype [CommonEvent.Conversation].
|
||||
*/
|
||||
/**
|
||||
* **Default implementation** (читает все events + фильтрует).
|
||||
*
|
||||
* Простая реализация через [events] + filterIsInstance. Реализации
|
||||
* могут override'нуть для эффективности (например, добавить SQL
|
||||
* `WHERE conversation_id = ?` чтобы не тянуть всё в память), но
|
||||
* контракт корректен и без override.
|
||||
*/
|
||||
fun conversationEvents(after: Instant?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||
events(after)
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.let { filtered ->
|
||||
if (conversationId == null) filtered
|
||||
else filtered.filter { it.conversationId == conversationId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe на **только agent events** ([CommonEvent.Agent] —
|
||||
* создание/удаление/переименование диалога).
|
||||
*
|
||||
* Семантика `after` идентична [events] (catchup + live).
|
||||
* Возвращаемый тип — конкретный subtype [CommonEvent.Agent].
|
||||
*
|
||||
* Полезно для admin-дашборда, который хочет видеть только lifecycle
|
||||
* диалогов без деталей ходов.
|
||||
*/
|
||||
/**
|
||||
* **Default implementation** (читает все events + фильтрует по типу).
|
||||
*
|
||||
* Простая реализация через [events] + filterIsInstance. Реализации
|
||||
* могут override'нуть для эффективности (например, читать только agent
|
||||
* row'ы из БД), но контракт корректен и без override.
|
||||
*/
|
||||
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> =
|
||||
events(after).filterIsInstance<CommonEvent.Agent>()
|
||||
|
||||
/**
|
||||
* Date **стартовой точки** буфера.
|
||||
*
|
||||
* - Если буфер не пуст → `date` самого старого буферизованного event'а.
|
||||
* - Если буфер пуст → текущее время (`Clock.System.now()` на момент вызова).
|
||||
*
|
||||
* **Семантика "now если пусто"** важна: позволяет клиенту безопасно
|
||||
* подписаться на [events](after = earliest) сразу — он получит только
|
||||
* новые live event'ы, без ложного catchup. Если бы возвращалось
|
||||
* `Instant.DISTANT_PAST` или `null` (с проверкой), клиент мог бы
|
||||
* ошибочно подписаться на несуществующий catchup и зависнуть в ожидании.
|
||||
*
|
||||
* **Используется клиентом для gap detection**:
|
||||
* - `lastSeen < earliest` → есть дыра в покрытии, нужен fallback
|
||||
* в message store за диапазоном `[lastSeen, earliest)`.
|
||||
* - `lastSeen >= earliest` → всё доступно через [events](after),
|
||||
* fallback не нужен.
|
||||
* - `lastSeen == earliest` → OK, первый live event будет > earliest.
|
||||
*
|
||||
* **Edge case**: клиент, подключившийся до того как store увидел хоть
|
||||
* один event, получает `earliest ≈ now`. Его `lastSeen` будет < earliest
|
||||
* — адаптируется в первом же poll'е и пойдёт через fallback если
|
||||
* сообщения audit log существуют (для consistency с прошлым).
|
||||
*
|
||||
* Suspend потому что в persistent impl'ах требует SQL query (`MIN(date)`
|
||||
* или `Clock.now()` для пустого буфера).
|
||||
*/
|
||||
suspend fun earliestEventDate(): Instant
|
||||
|
||||
override fun close()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-реализация [MutableOutboxStore] на `ArrayDeque` + `Mutex` — для тестов,
|
||||
// dev-режима и embedded-сценариев (Android core, CLI). TTL и size-cap eviction
|
||||
// вызываются на каждом `append`, в одном проходе с amortized O(1) для стабильного
|
||||
// размера буфера.
|
||||
//
|
||||
// Зависимости: только `:outbox-api` (api → `:proto` транзитивно).
|
||||
// Никакого I/O — pure in-memory.
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":outbox-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user