Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+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.
|
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||||
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
- Никакого persistent storage. Опирается на контракт `ContextStore`
|
||||||
(см. `:storage-core`).
|
(см. `:storage-core`).
|
||||||
|
|
||||||
## Текущий статус
|
## Текущий статус
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
|
api(project(":journal-api"))
|
||||||
api(project(":storage-core"))
|
api(project(":reflection-api"))
|
||||||
|
api(project(":context-api"))
|
||||||
|
|
||||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
|
|||||||
* Контекст, который тулсеты получают при активации.
|
* Контекст, который тулсеты получают при активации.
|
||||||
*
|
*
|
||||||
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
||||||
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
|
* добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||||
* могли читать/писать сообщения и память.
|
* могли читать/писать сообщения и память.
|
||||||
*
|
*
|
||||||
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.cli)
|
implementation(libs.kotlinx.cli)
|
||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
}
|
}
|
||||||
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
||||||
// весь код, включая platformEnv, лежит в commonMain.
|
// весь код, включая 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 kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
||||||
val id by argument(ArgType.String, description = "ID диалога")
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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)
|
val ok = agent.deleteConversation(id)
|
||||||
if (ok) println("deleted: $id") else println("conversation not found: $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.ArgType
|
||||||
import kotlinx.cli.default
|
import kotlinx.cli.default
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.proto.Agent
|
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)
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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))
|
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
||||||
if (convs.isEmpty()) {
|
if (convs.isEmpty()) {
|
||||||
println("(no conversations)")
|
println("(no conversations)")
|
||||||
|
|||||||
+2
-1
@@ -3,13 +3,14 @@ package pw.binom.agentik.cli.commands
|
|||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.cli.default
|
import kotlinx.cli.default
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
||||||
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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)
|
val conv = agent.createConversation(temp = temp)
|
||||||
println(conv.id)
|
println(conv.id)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ package pw.binom.agentik.cli.commands
|
|||||||
|
|
||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
||||||
@@ -9,7 +10,7 @@ class ConvRenameSubcommand : ConvSubcommand("rename", "Переименоват
|
|||||||
val title by argument(ArgType.String, description = "Новое название")
|
val title by argument(ArgType.String, description = "Новое название")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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 {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
|||||||
|
|
||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
||||||
val id by argument(ArgType.String, description = "ID диалога")
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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 {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
|||||||
|
|
||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
||||||
val id by argument(ArgType.String, description = "ID диалога")
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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 {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
|
|||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.cli.default
|
import kotlinx.cli.default
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Message
|
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)
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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 {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.flow.takeWhile
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||||
@@ -17,7 +18,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
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 {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ internal class TuiBackend(
|
|||||||
state.postSystem("подключено к ${state.config.server}")
|
state.postSystem("подключено к ${state.config.server}")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
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) {
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
// штатная отмена при закрытии UI
|
// штатная отмена при закрытии UI
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package pw.binom.agentik.tui
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
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.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.proto.Event
|
||||||
@@ -25,6 +26,16 @@ internal class FakeAgent(
|
|||||||
private set
|
private set
|
||||||
val conversations = mutableListOf<FakeConversation>()
|
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 {
|
override fun createConversation(temp: Boolean): Conversation {
|
||||||
createCount++
|
createCount++
|
||||||
val c = conversationFactory()
|
val c = conversationFactory()
|
||||||
@@ -40,8 +51,6 @@ internal class FakeAgent(
|
|||||||
|
|
||||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
||||||
conversations.toList()
|
conversations.toList()
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+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 фабрики.
|
- `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
|
||||||
- `AgentClient` — список и lifecycle диалогов.
|
`Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
|
||||||
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
|
||||||
`getMessages()`, `rename()`, `close()`.
|
- `Agent`: `createConversation` / `getConversation` / `getConversations` /
|
||||||
- Внутренний парсер SSE → `Flow<Event>`.
|
`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,
|
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||||
с теми же типами, что и сервер. Один и тот же клиент работает на
|
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
|
||||||
JVM, iOS, macOS, Linux, Windows.
|
|
||||||
|
|
||||||
## Где используется
|
## Подключение
|
||||||
|
|
||||||
- `:agentik-cli` — REPL.
|
|
||||||
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
|
||||||
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
|
||||||
|
|
||||||
## Как подключить
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// build.gradle.kts
|
// build.gradle.kts
|
||||||
kotlin {
|
dependencies {
|
||||||
sourceSets.commonMain.dependencies {
|
|
||||||
api("pw.binom.agentik:client:0.1.0")
|
api("pw.binom.agentik:client:0.1.0")
|
||||||
}
|
// Движок — на твой выбор (один из):
|
||||||
}
|
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
|
||||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
// Опционально — только если будешь использовать `InMemoryJournalStore`
|
||||||
val conv = agent.createConversation(title = "test")
|
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
|
||||||
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
api("pw.binom.agentik:journal-inmemory:0.1.0")
|
||||||
when (event) {
|
|
||||||
is Event.AppendText -> print(event.body)
|
|
||||||
is Event.End -> println("\n--- end ---")
|
|
||||||
is Event.Error -> error("agent error: ${event.message}")
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Версии
|
## Быстрый старт: свой клиент за 5 минут
|
||||||
|
|
||||||
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
Один self-contained пример: создаём агента, открываем диалог,
|
||||||
|
отправляем сообщение, печатаем streaming-ответ.
|
||||||
Поддерживает все KMP-таргеты, что и `:proto`.
|
|
||||||
|
|
||||||
## Примеры API
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// список диалогов
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
agent.getConversations().collect { println(it.id to it.title) }
|
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-подписка на события отдельного диалога
|
fun main() = runBlocking {
|
||||||
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
|
||||||
|
val agent = AgentikAgent(
|
||||||
|
id = "my-client",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
token = "s3cret", // или null, если не нужен
|
||||||
|
)
|
||||||
|
|
||||||
// прерывание текущего хода
|
// 2. Открыть диалог, отправить сообщение.
|
||||||
conversation.interrupt()
|
val conv = agent.createConversation(temp = false)
|
||||||
|
conv.send(listOf(Content.Text("Привет")))
|
||||||
|
|
||||||
// история
|
// 3. Собирать streaming-ответ.
|
||||||
conversation.getMessages(offset = 0).collect { msg ->
|
conv.events(after = Clock.System.now()).collect { ev ->
|
||||||
when (msg) {
|
when (ev) {
|
||||||
is Message.UserMessage -> println("user: ${msg.content}")
|
is Event.StartResponse -> println("[start]")
|
||||||
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
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
|
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
|
./gradlew :client:jvmTest
|
||||||
```
|
```
|
||||||
|
|
||||||
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||||
401/404.
|
401/404.
|
||||||
|
|
||||||
## Чего здесь НЕТ
|
|
||||||
|
|
||||||
- Никакого LLM-кода. Это просто клиент.
|
|
||||||
- Никакого persistent state. История хранится у сервера, клиент её
|
|
||||||
запрашивает через `getMessages` или подписывается через `events`.
|
|
||||||
|
|
||||||
## Текущий статус
|
|
||||||
|
|
||||||
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
|
||||||
но клиент совместим с любым сервером, который держит wire-контракт
|
|
||||||
`:server`.
|
|
||||||
|
|
||||||
## Известное ограничение
|
## Известное ограничение
|
||||||
|
|
||||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||||
terminal (TTY). Это upstream-особенность Ktor SSE.
|
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":proto"))
|
api(project(":proto"))
|
||||||
|
api(project(":outbox-api"))
|
||||||
|
api(project(":journal-api"))
|
||||||
|
|
||||||
implementation(libs.ktor.client.core)
|
api(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.cio)
|
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.test.host)
|
implementation(libs.ktor.server.test.host)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
implementation(libs.ktor.server.sse)
|
implementation(libs.ktor.server.sse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,39 +4,38 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.prepareGet
|
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
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.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.runBlocking
|
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.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||||
*
|
*
|
||||||
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
|
* HttpClient создаётся внутри из переданного engine и закрывается в [close].
|
||||||
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
|
*
|
||||||
* POST `/conversations`. Используем `runBlocking` — это одноразовая
|
* **Storage handles** ([journal], [outbox]) — read-only views на серверные
|
||||||
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
|
* хранилища.
|
||||||
* решает, что делать.
|
|
||||||
*/
|
*/
|
||||||
internal class AgentClient(
|
internal class AgentClient(
|
||||||
private val httpClient: HttpClient,
|
|
||||||
private val baseUrl: String,
|
|
||||||
override val id: String,
|
override val id: String,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: HttpClient,
|
||||||
) : Agent {
|
) : Agent {
|
||||||
|
|
||||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
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 =
|
override fun createConversation(temp: Boolean): Conversation =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
||||||
@@ -54,7 +53,7 @@ internal class AgentClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteConversation(id: String): Boolean {
|
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
|
return response.status == HttpStatusCode.NoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +65,7 @@ internal class AgentClient(
|
|||||||
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
override fun close() {
|
||||||
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
httpClient.close()
|
||||||
.execute { response ->
|
|
||||||
check(response.status == HttpStatusCode.OK) {
|
|
||||||
"events: server returned ${response.status}"
|
|
||||||
}
|
|
||||||
readSse(response.bodyAsChannel())
|
|
||||||
.collect { payload ->
|
|
||||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,43 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
|
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||||
* (модуль `:server`).
|
*
|
||||||
|
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
|
||||||
|
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
|
||||||
|
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
|
||||||
|
* снаружи — всё под капотом.
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* val client = AgentikAgent(
|
* val agent = AgentikAgent(
|
||||||
* id = "my-agent",
|
* id = "my-client",
|
||||||
* baseUrl = "http://localhost:8080/agentik",
|
* 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.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] (платформо-зависимый
|
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
||||||
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
|
* HttpClient (идемпотентно). После этого `createConversation` /
|
||||||
|
* `getConversation` etc. не определены.
|
||||||
*/
|
*/
|
||||||
fun AgentikAgent(
|
fun AgentikAgent(
|
||||||
id: String,
|
id: String,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
httpClient: HttpClient = defaultAgentikHttpClient(),
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
token: String? = null,
|
||||||
|
): Agent = AgentClient(
|
||||||
/**
|
id = id,
|
||||||
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
|
baseUrl = baseUrl,
|
||||||
* живёт в общем коде и плагина `SSEClientContent` не требует.
|
httpClient = agentikHttpClient(engineFactory = engineFactory, token = token),
|
||||||
*
|
)
|
||||||
* **Платформы:**
|
|
||||||
* - 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].
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
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.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
|
* Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
|
||||||
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
|
|
||||||
*
|
*
|
||||||
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
|
* Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
|
||||||
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
|
* не нужен — он передаёт engine в [AgentikAgent] и получает готовый
|
||||||
|
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
|
||||||
|
* на [pw.binom.agentik.proto.Agent.close].
|
||||||
|
*
|
||||||
|
* Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
|
||||||
|
* (например, дополнительные нестандартные запросы в обход `Agent` API).
|
||||||
*/
|
*/
|
||||||
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
fun agentikHttpClient(
|
||||||
engine { requestTimeout = 0 }
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
|
token: String? = null,
|
||||||
|
): HttpClient = HttpClient(engineFactory) {
|
||||||
install(ContentNegotiation) { json(agentikJson) }
|
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-шным
|
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||||
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
|
||||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||||
*
|
*
|
||||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
* Конфиг создаётся заново на каждый вызов — плагин `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
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
||||||
* Агент не должен с этим типом работать напрямую — он работает с
|
* Агент не должен с этим типом работать напрямую — он работает с
|
||||||
* [WorkingMemoryEntry] через [WorkingMemoryStore].
|
* [WorkingMemoryEntry] через [ContextStore].
|
||||||
*/
|
*/
|
||||||
data class WorkingMemoryRow(
|
data class WorkingMemoryRow(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
|
|||||||
*
|
*
|
||||||
* Суммаризация / чистка — один атомарный вызов [compact].
|
* Суммаризация / чистка — один атомарный вызов [compact].
|
||||||
*/
|
*/
|
||||||
interface WorkingMemoryStore : AutoCloseable {
|
interface ContextStore : AutoCloseable {
|
||||||
|
|
||||||
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.journal.MessageContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
* Запись в 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,
|
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
||||||
summarization-вставка отложена (нужен дизайн-проработка).
|
summarization-вставка отложена (нужен дизайн-проработка).
|
||||||
|
|
||||||
**`MessageStore`** — append-only аудит. На каждый ход дописываются
|
**`JournalStore`** — append-only аудит. На каждый ход дописываются
|
||||||
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
||||||
update/delete кроме каскада из `ConversationStore.delete`.
|
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` не пишется (модель не должна видеть ошибки прошлых ходов).
|
**Ошибки хода персистятся.** Если ход провалился (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
|
```kotlin
|
||||||
suspend fun append(record: MessageRecord)
|
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>
|
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||||
```
|
```
|
||||||
|
|
||||||
### `WorkingMemoryStore`
|
### `ContextStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,6 @@ plugins {
|
|||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
// Зеркалит набор :proto / :server / :memory-api — KMP-модуль с интерфейсами
|
|
||||||
// хранилища и разговорной истории, без платформенного IO. Конкретные
|
|
||||||
// реализации (sqlite, in-memory, android) живут в отдельных модулях.
|
|
||||||
jvm()
|
jvm()
|
||||||
macosX64()
|
macosX64()
|
||||||
macosArm64()
|
macosArm64()
|
||||||
+4
-9
@@ -1,17 +1,12 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Часть контента сообщения на уровне хранилища.
|
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
|
||||||
*
|
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
|
||||||
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
|
* в `Mapping.kt` storage impl'ов.
|
||||||
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
|
|
||||||
* идентичны, но даёт возможность заменить transport-протокол без миграции
|
|
||||||
* таблиц.
|
|
||||||
*
|
|
||||||
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface Content {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":memory-api"))
|
api(project(":memory-api"))
|
||||||
api(project(":storage-core"))
|
api(project(":journal-api"))
|
||||||
|
api(project(":reflection-api"))
|
||||||
|
api(project(":context-api"))
|
||||||
api(project(":skills"))
|
api(project(":skills"))
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
|
|||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
import pw.binom.agentik.memory.NewMemoryNote
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
import pw.binom.agentik.storage.Ids
|
import pw.binom.agentik.journal.Ids
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import pw.binom.litert.LiteConversationConfig
|
import pw.binom.litert.LiteConversationConfig
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import pw.binom.agentik.storage.Ids
|
import pw.binom.agentik.journal.Ids
|
||||||
import pw.binom.agentik.storage.Reflection
|
import pw.binom.agentik.reflection.Reflection
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ class LlmReflector(
|
|||||||
val parsed = ReflectionParser.parse(raw)
|
val parsed = ReflectionParser.parse(raw)
|
||||||
?: return@withContext null
|
?: return@withContext null
|
||||||
Reflection(
|
Reflection(
|
||||||
id = Ids.reflection(),
|
id = pw.binom.agentik.reflection.Ids.new(),
|
||||||
conversationId = null, // будет проставлен caller'ом ChatConversation
|
conversationId = null, // будет проставлен caller'ом ChatConversation
|
||||||
createdAt = clock.now(),
|
createdAt = clock.now(),
|
||||||
turnsAnalyzed = turns.size,
|
turnsAnalyzed = turns.size,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+1
-1
@@ -114,7 +114,7 @@ sealed interface Event {
|
|||||||
|
|
||||||
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
||||||
и `:client`.
|
и `:client`.
|
||||||
- Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
|
- Никакого хранения. Реализации `JournalStore` живут в `:storage-*`.
|
||||||
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
||||||
внутри `:standalone` (ChatAgent) и выше.
|
внутри `:standalone` (ChatAgent) и выше.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ kotlin {
|
|||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
// для JsonElement в MessageContext.metadata
|
// для JsonElement в MessageContext.metadata
|
||||||
api(libs.kotlinx.serialization.json)
|
api(libs.kotlinx.serialization.json)
|
||||||
|
// Read-only storage handles, выставляемые через Agent.journal
|
||||||
|
// и Agent.outbox. Типы JournalStore/OutboxStore фигурируют в
|
||||||
|
// public-сигнатуре Agent, поэтому api-висимости.
|
||||||
|
//
|
||||||
|
// Линейный граф зависимостей (без циклов):
|
||||||
|
// :proto ──► :outbox-api (нет обратной зависимости)
|
||||||
|
// :proto ──► :journal-api (нет обратной зависимости)
|
||||||
|
// Добились переносом AgentEvent/CommonEvent/Event из :proto в
|
||||||
|
// :outbox-api — они теперь self-contained в outbox (не нужны
|
||||||
|
// :proto-типы), а :proto использует их через :outbox-api.
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(project(":outbox-api"))
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package pw.binom.agentik.proto
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,12 +12,53 @@ import kotlin.time.Instant
|
|||||||
* Транспортно-агностично. [Agent] — фабрика stateful-диалогов:
|
* Транспортно-агностично. [Agent] — фабрика stateful-диалогов:
|
||||||
* [createConversation] возвращает [Conversation], который сам хранит историю
|
* [createConversation] возвращает [Conversation], который сам хранит историю
|
||||||
* и которому отправляют ходы через [Conversation.send].
|
* и которому отправляют ходы через [Conversation.send].
|
||||||
|
*
|
||||||
|
* **Хранилища вынесены в [Agent.journal] и [Agent.outbox]**: оба read-only.
|
||||||
|
* События больше НЕ часть [Agent] (раньше были `events()`/`allEvents()`) —
|
||||||
|
* они теперь живут в [outbox] как `OutboxStore.events(after)` /
|
||||||
|
* `outbox.agentEvents(after)`. Это даёт единый путь для всех read-операций
|
||||||
|
* по хранилищу и убирает дублирование между протоколом и хранилищем.
|
||||||
*/
|
*/
|
||||||
public interface Agent {
|
interface Agent : AutoCloseable {
|
||||||
|
|
||||||
/** Идентификатор агента. */
|
/** Идентификатор агента. */
|
||||||
val id: String
|
val id: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки).
|
||||||
|
* После [close] вызовы [createConversation] / [getConversation] и т.п.
|
||||||
|
* не определены. Idempotent.
|
||||||
|
*/
|
||||||
|
override fun close()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log всех сообщений диалогов (read-only view).
|
||||||
|
*
|
||||||
|
* Используется HTTP-фасадом `:server` для endpoint'а
|
||||||
|
* `GET /{path}/journal/conversations/{id}/messages` — внешние клиенты
|
||||||
|
* (дашборды, parent-агенты, A2A-bridge) могут читать полный transcript
|
||||||
|
* диалога, включая tool-call/tool-result/error, без необходимости идти
|
||||||
|
* через `Conversation.getMessages` (который возвращает уже
|
||||||
|
* project'нутый proto-Message).
|
||||||
|
*
|
||||||
|
* **Read-only**: write-доступ только через `MutableJournalStore`
|
||||||
|
* внутри ChatAgent / ConversationLoop, не через [Agent] interface.
|
||||||
|
*/
|
||||||
|
val journal: JournalStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded-tail live event stream агента (read-only view).
|
||||||
|
*
|
||||||
|
* Используется HTTP-фасадом `:server` для endpoint'а
|
||||||
|
* `GET /{path}/outbox/events?after=` (SSE) — внешние клиенты подписываются
|
||||||
|
* на agent lifecycle + conversation events. Catchup+live контракт — см.
|
||||||
|
* KDoc `OutboxStore.events`.
|
||||||
|
*
|
||||||
|
* **Read-only**: write-доступ только через `MutableOutboxStore` внутри
|
||||||
|
* ChatAgent / ConversationLoop, не через [Agent] interface.
|
||||||
|
*/
|
||||||
|
val outbox: OutboxStore
|
||||||
|
|
||||||
/** Создаёт новый stateful-диалог с агентом. */
|
/** Создаёт новый stateful-диалог с агентом. */
|
||||||
fun createConversation(temp: Boolean): Conversation
|
fun createConversation(temp: Boolean): Conversation
|
||||||
|
|
||||||
@@ -39,16 +82,6 @@ public interface Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Live-подписка на изменения в множестве диалогов агента: создание,
|
|
||||||
* удаление, переименование (см. [AgentEvent]). События внутри конкретного
|
|
||||||
* диалога приходят через [Conversation.events].
|
|
||||||
*
|
|
||||||
* **Не реплеит** прошлое — для снимка множества используй [getConversations]
|
|
||||||
* или [getConversation].
|
|
||||||
*/
|
|
||||||
fun events(after: Instant): Flow<AgentEvent>
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val PAGE_SIZE: Int = 100
|
const val PAGE_SIZE: Int = 100
|
||||||
|
|||||||
@@ -1,43 +1,10 @@
|
|||||||
package pw.binom.agentik.proto
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live-события уровня [Agent]: изменения в множестве диалогов
|
* Backward-compat typealias: `AgentEvent` теперь живёт в `:outbox-api`
|
||||||
* (создание, удаление, переименование). События, происходящие **внутри**
|
* (логически принадлежит сущности outbox, не wire-протоколу `:proto`).
|
||||||
* конкретного диалога, приходят через [Conversation.events], а не сюда.
|
|
||||||
*
|
*
|
||||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
|
* Существующие импорты `pw.binom.agentik.proto.AgentEvent` продолжают
|
||||||
* идентична [Conversation.events]: поток **не реплеит** прошлое, для бэкфилла
|
* работать транспарентно. Использовать typealias в новом коде.
|
||||||
* используются `getConversations`/`getConversation`.
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
typealias AgentEvent = pw.binom.agentik.outbox.AgentEvent
|
||||||
sealed interface AgentEvent {
|
|
||||||
/** Момент эмиссии события в UTC. */
|
|
||||||
val date: Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создан новый диалог. Передаётся его id — handle можно получить через
|
|
||||||
* [Agent.getConversation]. Подписчик после [Created] может сразу открыть
|
|
||||||
* live-подписку на этот диалог через [Conversation.events].
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
@SerialName("created")
|
|
||||||
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Диалог удалён. Переданный [Conversation]-handle реализация обязана
|
|
||||||
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
|
|
||||||
* пользоваться handle нельзя.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
@SerialName("deleted")
|
|
||||||
data class Deleted(override val date: Instant, val id: String) : AgentEvent
|
|
||||||
|
|
||||||
/** У диалога сменился заголовок. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("renamed")
|
|
||||||
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compat typealias: `CommonEvent` теперь живёт в `:outbox-api`.
|
||||||
|
*
|
||||||
|
* Существующие импорты `pw.binom.agentik.proto.CommonEvent` продолжают
|
||||||
|
* работать транспарентно.
|
||||||
|
*/
|
||||||
|
typealias CommonEvent = pw.binom.agentik.outbox.CommonEvent
|
||||||
@@ -1,88 +1,11 @@
|
|||||||
package pw.binom.agentik.proto
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Элемент live-потока [Conversation.events].
|
* Backward-compat typealias: `Event` теперь живёт в `:outbox-api`.
|
||||||
*
|
*
|
||||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
* Существующие импорты `pw.binom.agentik.proto.Event` продолжают
|
||||||
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
* работать транспарентно. `Conversation.events(after): Flow<Event>` в
|
||||||
* порядка при равных timestamps.
|
* `:proto.Conversation` теперь фактически возвращает
|
||||||
*
|
* `pw.binom.agentik.outbox.Event` — тот же тип, другое имя.
|
||||||
* Базовая структура хода:
|
|
||||||
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
|
||||||
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
typealias Event = pw.binom.agentik.outbox.Event
|
||||||
sealed interface Event {
|
|
||||||
/** Момент эмиссии события в UTC. */
|
|
||||||
val date: Instant
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class ResponseType {
|
|
||||||
@SerialName("text") TEXT,
|
|
||||||
@SerialName("image") IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("start_reasoning")
|
|
||||||
data class StartReasoning(override val date: Instant) : Event
|
|
||||||
|
|
||||||
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("start_response")
|
|
||||||
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
|
||||||
|
|
||||||
/** Ход завершён нормально. Соответствующий [Message.AssistantMessage] появится в `getMessages`. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("end")
|
|
||||||
data class End(override val date: Instant) : Event
|
|
||||||
|
|
||||||
/** Ход прерван через [Conversation.interrupt]. Частичный ответ НЕ сохраняется в истории. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("interrupted")
|
|
||||||
data class Interrupted(override val date: Instant) : Event
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("append_text")
|
|
||||||
data class AppendText(override val date: Instant, val body: String) : Event
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("append_image")
|
|
||||||
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
|
||||||
* [id] совпадает с id соответствующего [Message.ToolCall] в истории
|
|
||||||
* после завершения хода.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
@SerialName("tool_call")
|
|
||||||
data class ToolCall(
|
|
||||||
override val date: Instant,
|
|
||||||
val id: String,
|
|
||||||
val title: String?,
|
|
||||||
val toolName: String,
|
|
||||||
val toolArgs: String,
|
|
||||||
) : Event
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
|
||||||
* [id] совпадает с [ToolCall.id], к которому относится результат, и
|
|
||||||
* с id [Message.ToolResult] в истории.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
@SerialName("tool_result")
|
|
||||||
data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
|
||||||
* прийти, но ход считается проваленным.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
@SerialName("error")
|
|
||||||
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ kotlin {
|
|||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":proto"))
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
// Read-only storage handles, которые HTTP-фасад выставляет наружу
|
||||||
|
// под {path}/journal/* и {path}/outbox/*. Типы JournalStore/
|
||||||
|
// OutboxStore фигурируют в сигнатурах internal-функций
|
||||||
|
// journalRoutes/outboxRoutes, поэтому нужны в compile classpath.
|
||||||
|
// Транзитивные api-висимости :proto (:journal-api, :outbox-api)
|
||||||
|
// не доходят до :server из-за implementation(:proto), поэтому
|
||||||
|
// объявляем напрямую.
|
||||||
|
implementation(project(":journal-api"))
|
||||||
|
implementation(project(":outbox-api"))
|
||||||
|
|
||||||
// Ktor (без engine — engine подключает потребитель, см. :standalone).
|
// Ktor (без engine — engine подключает потребитель, см. :standalone).
|
||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.content.negotiation)
|
implementation(libs.ktor.server.content.negotiation)
|
||||||
@@ -37,6 +47,8 @@ kotlin {
|
|||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.ktor.server.test.host)
|
||||||
|
implementation(libs.ktor.server.cio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.createRouteScopedPlugin
|
||||||
|
import io.ktor.server.request.path
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфиг плагина проверки `Authorization: Bearer <token>` для роута `agentikAgent`.
|
||||||
|
*
|
||||||
|
* По умолчанию [token] == null → плагин пропускает все запросы (см. [Module.kt]).
|
||||||
|
*/
|
||||||
|
internal class BearerTokenConfig {
|
||||||
|
var token: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-scoped плагин: если в конфиге задан [BearerTokenConfig.token], на каждый
|
||||||
|
* запрос внутри ветки роута проверяет заголовок `Authorization: Bearer <token>`.
|
||||||
|
* При несовпадении отвечает `401 Unauthorized` (тело `Unauthorized`); дальнейшие
|
||||||
|
* обработчики не вызываются — ktor трактует отправленный ответ как завершение
|
||||||
|
* call-pipeline.
|
||||||
|
*
|
||||||
|
* `/health` всегда пропускается без проверки: это ручка liveness для
|
||||||
|
* балансировщика/мониторинга, закрывать её — сломать health-check.
|
||||||
|
*/
|
||||||
|
internal val BearerTokenPlugin = createRouteScopedPlugin(
|
||||||
|
name = "AgentikBearerToken",
|
||||||
|
createConfiguration = ::BearerTokenConfig,
|
||||||
|
) {
|
||||||
|
val expected = pluginConfig.token
|
||||||
|
onCall { call ->
|
||||||
|
if (expected == null) return@onCall
|
||||||
|
val path = call.request.path()
|
||||||
|
if (path.endsWith("/health")) return@onCall
|
||||||
|
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
|
||||||
|
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.route
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-фасад для [JournalStore] (append-only audit log сообщений диалога).
|
||||||
|
*
|
||||||
|
* **Основа:** функция-продолжение для [Route.agentikAgent] — внутри неё уже
|
||||||
|
* создан роут под `{path}` агента. [journalRoutes] добавляет под-prefix
|
||||||
|
* [path] (по умолчанию `"/journal"`) к **этому же** родительскому роуту,
|
||||||
|
* итоговый URL = `{path агента}/journal/...`.
|
||||||
|
*
|
||||||
|
* **Endpoint'ы под `{path}/journal`:**
|
||||||
|
* - `GET /conversations/{id}/messages?after=&offset=&limit=` — список raw
|
||||||
|
* [pw.binom.agentik.journal.MessageRecord] (все типы: UserMessage /
|
||||||
|
* AssistantMessage / ToolCall / ToolResult / Error). В отличие от
|
||||||
|
* `GET /conversations/{id}/messages` в [agentikRoutes] (который отдаёт
|
||||||
|
* project'нутые proto-[pw.binom.agentik.proto.Message]), здесь клиент
|
||||||
|
* получает полный transcript с tool-call/tool-result/error payload-ами,
|
||||||
|
* turn-tokens и context-метаданными.
|
||||||
|
*
|
||||||
|
* **Read-only:** [JournalStore] не имеет `append` — запись только через
|
||||||
|
* writer-референс, который ChatAgent держит внутри (тип `MutableJournalStore`,
|
||||||
|
* не выставлен наружу через [pw.binom.agentik.proto.Agent]).
|
||||||
|
*/
|
||||||
|
fun Route.journalRoutes(
|
||||||
|
journal: JournalStore,
|
||||||
|
path: String = "/journal",
|
||||||
|
) {
|
||||||
|
route(path) {
|
||||||
|
get("/conversations/{id}/messages") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val after = call.parseAfter() ?: return@get
|
||||||
|
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||||
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
|
||||||
|
call.respond(journal.list(id, after, offset, limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,24 +20,50 @@ import pw.binom.agentik.proto.Agent
|
|||||||
* }.start(wait = true)
|
* }.start(wait = true)
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Под префиксом [path] монтируются:
|
* **Все** дочерние фасады ([agentikRoutes] / [journalRoutes] / [outboxRoutes])
|
||||||
* - `POST /conversations` — создать диалог
|
* монтируются внутри `{path}` — внутренний `route(path)` создаёт родительский
|
||||||
* - `GET /conversations` — список
|
* роут агента, и storage-фасады добавляют свои под-prefix'ы **к этому же**
|
||||||
* - `GET /conversations/{id}` — один диалог
|
* роуту, а не к корню. Итоговая раскладка:
|
||||||
* - `PATCH /conversations/{id}` — переименовать
|
*
|
||||||
* - `DELETE /conversations/{id}` — удалить
|
* ```
|
||||||
* - `POST /conversations/{id}/messages` — `send` (202 Accepted)
|
* POST {path}/conversations
|
||||||
* - `POST /conversations/{id}/interrupt` — `interrupt`
|
* GET {path}/conversations
|
||||||
* - `GET /conversations/{id}/messages` — история
|
* GET {path}/conversations/{id}
|
||||||
* - `GET /conversations/{id}/events` — SSE: события хода
|
* PATCH {path}/conversations/{id}
|
||||||
* - `GET /events` — SSE: события агента
|
* DELETE {path}/conversations/{id}
|
||||||
* - `GET /health` — `"ok"`
|
* POST {path}/conversations/{id}/messages
|
||||||
|
* POST {path}/conversations/{id}/interrupt
|
||||||
|
* GET {path}/conversations/{id}/messages
|
||||||
|
* GET {path}/conversations/{id}/events (SSE: события хода)
|
||||||
|
* GET {path}/events (SSE: agent-level events)
|
||||||
|
* GET {path}/health
|
||||||
|
* GET {path}/journal/conversations/{id}/messages
|
||||||
|
* GET {path}/outbox/events (SSE: outbox catchup+live)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Под-prefix'ы `/journal` и `/outbox` выбраны чтобы не пересекаться с
|
||||||
|
* существующим `/events` (agent-level SSE) и
|
||||||
|
* `/conversations/{id}/messages` (proto-Message'ы, не raw records).
|
||||||
*/
|
*/
|
||||||
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") {
|
fun Route.agentikAgent(
|
||||||
|
agent: Agent,
|
||||||
|
path: String = "/agentik",
|
||||||
|
token: String? = null,
|
||||||
|
) {
|
||||||
route(path) {
|
route(path) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(agentikJson)
|
json(agentikJson)
|
||||||
}
|
}
|
||||||
|
if (token != null) {
|
||||||
|
install(BearerTokenPlugin) {
|
||||||
|
this.token = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Proto-роуты: диалоги, send/interrupt, events (agent-level).
|
||||||
agentikRoutes(agent)
|
agentikRoutes(agent)
|
||||||
|
|
||||||
|
// Storage-фасады: те же `this` (роут агента), свои под-prefix'ы.
|
||||||
|
journalRoutes(agent.journal)
|
||||||
|
outboxRoutes(agent.outbox)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.route
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.proto.CommonEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента).
|
||||||
|
*
|
||||||
|
* **Основа:** функция-продолжение для [Route.agentikAgent] — внутри неё уже
|
||||||
|
* создан роут под `{path}` агента. [outboxRoutes] добавляет под-prefix
|
||||||
|
* [path] (по умолчанию `"/outbox"`) к **этому же** родительскому роуту,
|
||||||
|
* итоговый URL = `{path агента}/outbox/...`.
|
||||||
|
*
|
||||||
|
* **Endpoint'ы под `{path}/outbox`:**
|
||||||
|
* - `GET /events?after=` — SSE (catchup + live) в формате `data: <json>\n\n`,
|
||||||
|
* где `<json>` — сериализованный [CommonEvent].
|
||||||
|
* Семантика `after` идентична [OutboxStore.events]:
|
||||||
|
* - `after` отсутствует → только live (события с момента подписки).
|
||||||
|
* - `after` задан → сначала catchup всех буферизованных событий с
|
||||||
|
* `date > after`, потом live.
|
||||||
|
*
|
||||||
|
* **Покрытие:** outbox — это короткий bounded tail с auto-TTL. Для событий
|
||||||
|
* старше буфера клиент должен идти в `/journal/conversations/{id}/messages`
|
||||||
|
* (полный audit log), см. KDoc [OutboxStore].
|
||||||
|
*
|
||||||
|
* **Read-only:** [OutboxStore] не имеет `append` — запись только через
|
||||||
|
* writer-референс, который ChatAgent держит внутри (тип `MutableOutboxStore`,
|
||||||
|
* не выставлен наружу через [pw.binom.agentik.proto.Agent]).
|
||||||
|
*/
|
||||||
|
fun Route.outboxRoutes(
|
||||||
|
outbox: OutboxStore,
|
||||||
|
path: String = "/outbox",
|
||||||
|
) {
|
||||||
|
route(path) {
|
||||||
|
get("/events") {
|
||||||
|
val after = call.parseAfter() ?: return@get
|
||||||
|
// SSE-стрим: catchup (если `after` != DISTANT_PAST) + live tail.
|
||||||
|
call.streamJsonSse(outbox.events(after), CommonEvent.serializer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package pw.binom.agentik.server
|
|||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.ApplicationCall
|
import io.ktor.server.application.ApplicationCall
|
||||||
import io.ktor.server.application.call
|
|
||||||
import io.ktor.server.request.receive
|
import io.ktor.server.request.receive
|
||||||
import io.ktor.server.request.receiveText
|
import io.ktor.server.request.receiveText
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
@@ -17,10 +16,12 @@ import io.ktor.server.routing.post
|
|||||||
import io.ktor.utils.io.writeStringUtf8
|
import io.ktor.utils.io.writeStringUtf8
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
import pw.binom.agentik.proto.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.CommonEvent
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.proto.Event
|
||||||
@@ -132,7 +133,23 @@ internal fun Route.agentikRoutes(agent: Agent) {
|
|||||||
|
|
||||||
get("/events") {
|
get("/events") {
|
||||||
val after = call.parseAfter() ?: return@get
|
val after = call.parseAfter() ?: return@get
|
||||||
call.streamJsonSse(agent.events(after), AgentEvent.serializer())
|
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
||||||
|
// распаковываем .event для обратной совместимости с прежним
|
||||||
|
// форматом (когда был Agent.events(): Flow<AgentEvent>).
|
||||||
|
call.streamJsonSse(
|
||||||
|
agent.outbox.agentEvents(after).map { it.event },
|
||||||
|
AgentEvent.serializer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все события в одном потоке: agent lifecycle + все conversation events.
|
||||||
|
* Для admin-дашборда, debug-инструментов, parent-агента.
|
||||||
|
* Для UI достаточно `/events` + `/conversations/{id}/events`.
|
||||||
|
*/
|
||||||
|
get("/events/all") {
|
||||||
|
val after = call.parseAfter() ?: return@get
|
||||||
|
call.streamJsonSse(agent.outbox.events(after), CommonEvent.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +159,7 @@ internal fun Route.agentikRoutes(agent: Agent) {
|
|||||||
* Парсит query-параметр `after` как ISO-8601 [Instant]. Отсутствие = [Instant.DISTANT_PAST].
|
* Парсит query-параметр `after` как ISO-8601 [Instant]. Отсутствие = [Instant.DISTANT_PAST].
|
||||||
* При невалидном значении отвечает 400 и возвращает `null`.
|
* При невалидном значении отвечает 400 и возвращает `null`.
|
||||||
*/
|
*/
|
||||||
private suspend fun ApplicationCall.parseAfter(): Instant? {
|
internal suspend fun ApplicationCall.parseAfter(): Instant? {
|
||||||
val raw = request.queryParameters["after"]
|
val raw = request.queryParameters["after"]
|
||||||
if (raw == null) return Instant.DISTANT_PAST
|
if (raw == null) return Instant.DISTANT_PAST
|
||||||
return try {
|
return try {
|
||||||
@@ -153,7 +170,7 @@ private suspend fun ApplicationCall.parseAfter(): Instant? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> ApplicationCall.streamJsonSse(
|
internal suspend fun <T> ApplicationCall.streamJsonSse(
|
||||||
flow: Flow<T>,
|
flow: Flow<T>,
|
||||||
serializer: KSerializer<T>,
|
serializer: KSerializer<T>,
|
||||||
json: Json = agentikJson,
|
json: Json = agentikJson,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.cio.CIO as ServerCIO
|
||||||
|
import io.ktor.server.engine.EmbeddedServer
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты route-scoped плагина [BearerTokenPlugin]:
|
||||||
|
* - при `token != null` все роуты кроме `/health` требуют `Authorization: Bearer <token>`;
|
||||||
|
* - при `token == null` плагин не устанавливается, всё открыто;
|
||||||
|
* - `/health` всегда открыт, даже при заданном токене (liveness-ручка для балансировщика).
|
||||||
|
*/
|
||||||
|
class BearerTokenTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub-реализации storage-handle для теста bearer-токена — контент не
|
||||||
|
* используется, тесты проверяют только что endpoint'ы закрыты/открыты
|
||||||
|
* по токену. Storage routes регистрируются сразу в [agentikAgent] и
|
||||||
|
* вычитывают [journal]/[outbox] eagerly, поэтому возвращаем noop-реализации
|
||||||
|
* (а не `error("...")`), иначе старт сервера валится на инициализации.
|
||||||
|
*/
|
||||||
|
private class FakeAgent(
|
||||||
|
override val id: String = "test",
|
||||||
|
) : Agent {
|
||||||
|
override val journal: JournalStore = object : JournalStore {
|
||||||
|
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
|
||||||
|
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
override val outbox: OutboxStore = object : OutboxStore {
|
||||||
|
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
|
||||||
|
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
|
||||||
|
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
|
||||||
|
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
|
||||||
|
override suspend fun getConversation(id: String): Conversation? = null
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean = false
|
||||||
|
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = emptyList()
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun startServer(token: String?): Pair<EmbeddedServer<*, *>, Int> {
|
||||||
|
val server = embeddedServer(ServerCIO, port = 0) {
|
||||||
|
routing {
|
||||||
|
agentikAgent(FakeAgent(), path = "/agentik", token = token)
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
val port = server.engine.resolvedConnectors().first().port
|
||||||
|
return server to port
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tokenRejectsRequestWithoutHeader() = runBlocking {
|
||||||
|
val (server, port) = startServer("secret")
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||||
|
assertEquals("Unauthorized", resp.bodyAsText())
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tokenRejectsWrongHeader() = runBlocking {
|
||||||
|
val (server, port) = startServer("secret")
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer wrong")
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tokenAcceptsCorrectHeader() = runBlocking {
|
||||||
|
val (server, port) = startServer("secret")
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer secret")
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun healthStaysOpenWithToken() = runBlocking {
|
||||||
|
val (server, port) = startServer("secret")
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/health")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
assertEquals("ok", resp.bodyAsText())
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nullTokenMeansOpen() = runBlocking {
|
||||||
|
val (server, port) = startServer(null)
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-9
@@ -46,7 +46,8 @@ include(":server")
|
|||||||
include(":client")
|
include(":client")
|
||||||
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
||||||
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
||||||
include(":agentik-cli")
|
// include(":agentik-cli") — отключено 2026-09-21: пользователь временно вывел
|
||||||
|
// из сборки. Папка agentik-cli/ осталась на диске для возможного возврата.
|
||||||
// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton),
|
// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton),
|
||||||
// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios).
|
// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios).
|
||||||
// include(":agentik-tui") — отключено 2026-09-17: пользователь признал TUI-подход неудачным.
|
// include(":agentik-tui") — отключено 2026-09-17: пользователь признал TUI-подход неудачным.
|
||||||
@@ -63,17 +64,61 @@ include(":memory-vector")
|
|||||||
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore /
|
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore /
|
||||||
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации —
|
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации —
|
||||||
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred).
|
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred).
|
||||||
include(":storage-core")
|
// In-memory реализация store'ов из :journal-api и :context-api. KMP,
|
||||||
// In-memory реализация всех 4 store'ов из :storage-core. KMP, без платформенных
|
|
||||||
// IO-зависимостей. Используется в тестах (быстрый setup, без JDBC) и будет
|
// IO-зависимостей. Используется в тестах (быстрый setup, без JDBC) и будет
|
||||||
// использоваться в Android-сборке (JVector/SQLite не подходят для ART out-of-box).
|
// использоваться в Android-сборке (JVector/SQLite не подходят для ART out-of-box).
|
||||||
|
//include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids)
|
||||||
|
// и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids)
|
||||||
|
// Модуль удалён с диска.
|
||||||
|
include(":reflection-api")
|
||||||
|
//include(":message-log-api")
|
||||||
|
//include(":message-log-ksqlite")
|
||||||
|
// Новые API-модули трёх сущностей (canonical имена):
|
||||||
|
// - :journal-api — append-only audit log (бывший :message-log-api)
|
||||||
|
// - :context-api — то что видит LLM (бывший :working-memory-api)
|
||||||
|
// - :outbox-api — bounded-tail event stream (бывший :event-store)
|
||||||
|
// Старые модули :message-log-api / :working-memory-api / :event-store
|
||||||
|
// остаются на диске — миграция consumers'ов по чуть-чуть, отдельно.
|
||||||
|
include(":journal-api")
|
||||||
|
include(":context-api")
|
||||||
|
include(":outbox-api")
|
||||||
|
// Bounded-tail event log с auto-TTL. Двухуровневое хранилище: этот модуль —
|
||||||
|
// короткий live tail + recent replay; полный audit log живёт в :message-store-api
|
||||||
|
// (там — MessageStore + ConversationStore). EventStore сам управляет eviction,
|
||||||
|
// никаких prune-методов наружу. KMP, без implementations пока.
|
||||||
|
//include(":event-store")
|
||||||
|
// KMP in-memory реализация MutableEventStore. ConcurrentLinkedDeque + TTL/size
|
||||||
|
// eviction. Для тестов, dev-режима, embedded-сценариев (Android core).
|
||||||
|
include(":outbox-inmemory")
|
||||||
|
include(":journal-inmemory")
|
||||||
|
//include(":event-store-in-memory")
|
||||||
|
//include(":working-memory-api")
|
||||||
include(":storage-inmemory")
|
include(":storage-inmemory")
|
||||||
// SQLDelight-реализация всех 4 store'ов из :storage-core. JVM-only (SQLDelight
|
// SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only
|
||||||
// native драйверов для KMP вне JVM пока не публикует). Содержит 4 .sq-файла +
|
// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
|
||||||
// 5 классов: SqliteStores, SqliteConversationStore, SqliteMessageStore,
|
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
|
||||||
// SqliteWorkingMemoryStore, SqliteReflectionStore. Бэкенд для прод-запуска
|
// на KMP-таргетах (JVM + linuxX64 + mingwX64). Apple targets auto-disabled на
|
||||||
// :standalone (путь к .db файлу в AGENTIK_DB).
|
// Linux — собираются локально на macOS. Пока покрывает только EventStore;
|
||||||
include(":storage-sqlite")
|
// остальные store'ы мигрируют после стабилизации ksqlite и реального
|
||||||
|
// использования на Android-агенте.
|
||||||
|
include(":storage-ksqlite")
|
||||||
|
// ksqlite-реализация :context-api (ContextStore / working_memory table).
|
||||||
|
// Минимальный модуль: только таблица `working_memory` + 2 индекса.
|
||||||
|
// Параллельно существует :storage-ksqlite/KsqliteWorkingMemoryStore.kt —
|
||||||
|
// миграция consumers'ов по чуть-чуть, отдельно.
|
||||||
|
include(":context-ksqlite")
|
||||||
|
// ksqlite-реализация :journal-api (JournalStore / message table).
|
||||||
|
// Минимальный модуль: только таблица `message` + 1 индекс
|
||||||
|
// `(conversation_id, created_at)`. Параллельно существует
|
||||||
|
// :storage-ksqlite/KsqliteMessageStore.kt — миграция consumers'ов
|
||||||
|
// по чуть-чуть, отдельно.
|
||||||
|
include(":journal-ksqlite")
|
||||||
|
// KMP-реализация EventStore через ksqlite (https://github.com/caffeine-mgn/ksqlite).
|
||||||
|
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
|
||||||
|
// на KMP-таргетах (JVM + linuxX64 + mingwX64). Apple targets auto-disabled на
|
||||||
|
// Linux — собираются локально на macOS. На этом этапе покрывает только EventStore;
|
||||||
|
// остальные store'ы мигрируют после стабилизации ksqlite (>=0.2) и реального
|
||||||
|
// использования на Android-агенте.
|
||||||
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
|
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
|
||||||
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
|
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
|
||||||
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
|
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AGENTIK_PORT` | `8080` | Порт HTTP-сервера |
|
| `AGENTIK_PORT` | `8080` | Порт HTTP-сервера |
|
||||||
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite |
|
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite |
|
||||||
|
| `AGENTIK_TOKEN` | (пусто) | Bearer-токен для HTTP-фасада `/agentik`. Пусто — авторизация выключена |
|
||||||
|
| `AGENTIK_A2A_TOKEN` | (пусто) | Bearer-токен для A2A-фасада `/a2a`. Пусто — авторизация выключена (независим от `AGENTIK_TOKEN`) |
|
||||||
| `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) |
|
| `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) |
|
||||||
| `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` |
|
| `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` |
|
||||||
| `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели |
|
| `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели |
|
||||||
|
|||||||
+29
-24
@@ -1,10 +1,9 @@
|
|||||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
|
||||||
|
import org.gradle.api.artifacts.ConfigurationContainer
|
||||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget
|
|
||||||
|
|
||||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
import org.gradle.api.artifacts.ConfigurationContainer
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
@@ -19,6 +18,12 @@ val skipVectorMemory: Boolean =
|
|||||||
(project.findProperty("skipVectorMemory") == "true") ||
|
(project.findProperty("skipVectorMemory") == "true") ||
|
||||||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||||
|
|
||||||
|
// :standalone — единственный entry-point проекта, JVM-only по факту (один
|
||||||
|
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
|
||||||
|
// commonMain + commonTest — даже зависимости, которые формально JVM-only
|
||||||
|
// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут
|
||||||
|
// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`,
|
||||||
|
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
@@ -32,21 +37,18 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
// Протокол + KMP storage API
|
||||||
implementation(project(":proto"))
|
implementation(project(":proto"))
|
||||||
implementation(project(":server"))
|
implementation(project(":server"))
|
||||||
|
implementation(project(":journal-api"))
|
||||||
|
implementation(project(":outbox-api"))
|
||||||
|
implementation(project(":context-api"))
|
||||||
|
implementation(project(":reflection-api"))
|
||||||
|
|
||||||
// Commons
|
// Commons
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// litert-kmp: контракт (commonMain)
|
|
||||||
api(libs.litert.api)
|
|
||||||
|
|
||||||
// litert-openai: JVM-реализация
|
|
||||||
implementation(libs.litert.openai)
|
|
||||||
}
|
|
||||||
|
|
||||||
jvmMain.dependencies {
|
|
||||||
// Парсер и загрузчик скилов (YAML frontmatter + markdown body).
|
// Парсер и загрузчик скилов (YAML frontmatter + markdown body).
|
||||||
implementation(project(":skills"))
|
implementation(project(":skills"))
|
||||||
|
|
||||||
@@ -56,16 +58,25 @@ kotlin {
|
|||||||
if (!skipVectorMemory) {
|
if (!skipVectorMemory) {
|
||||||
implementation(project(":memory-vector"))
|
implementation(project(":memory-vector"))
|
||||||
}
|
}
|
||||||
implementation(project(":storage-core"))
|
|
||||||
implementation(project(":storage-sqlite"))
|
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
|
||||||
|
implementation(project(":storage-ksqlite"))
|
||||||
|
|
||||||
|
// Bounded-tail live event stream + per-event TTL.
|
||||||
|
implementation(project(":outbox-inmemory"))
|
||||||
|
|
||||||
|
// LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner,
|
||||||
|
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
|
||||||
implementation(project(":agent-toolsets"))
|
implementation(project(":agent-toolsets"))
|
||||||
// Generic LLM-side tools (LlmReflector, SkillMiner, LlmMemoryReviewer,
|
|
||||||
// ContextCompactor, парсеры/промпты). Вынесены из :standalone.
|
|
||||||
implementation(project(":llm-tools"))
|
implementation(project(":llm-tools"))
|
||||||
|
|
||||||
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
||||||
// Вынесен из :standalone — generic мост между MCP-SDK и LiteTool.
|
|
||||||
implementation(project(":mcp-bridge"))
|
implementation(project(":mcp-bridge"))
|
||||||
|
|
||||||
|
// litert-kmp: контракт (commonMain)
|
||||||
|
api(libs.litert.api)
|
||||||
|
// litert-openai: JVM-реализация
|
||||||
|
implementation(libs.litert.openai)
|
||||||
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
||||||
runtimeOnly(libs.litert.google)
|
runtimeOnly(libs.litert.google)
|
||||||
|
|
||||||
@@ -79,7 +90,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
// Транспортные фасады (A2A остаётся заготовкой; на v1 не подключается в Main.kt —
|
// Транспортные фасады (A2A остаётся заготовкой; на v1 не подключается в Main.kt —
|
||||||
// см. docs/ARCHITECTURE.md §3).
|
// см. docs/ARCHITECTURE.md §3).
|
||||||
implementation(libs.a2a.server)
|
implementation(libs.a2a.server)
|
||||||
|
|
||||||
// MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов
|
// MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов
|
||||||
@@ -87,7 +98,6 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
|
||||||
|
|
||||||
// Логирование: kotlin-logging (тонкая обёртка slf4j-api) + logback-classic
|
// Логирование: kotlin-logging (тонкая обёртка slf4j-api) + logback-classic
|
||||||
// (binding для JVM; без него slf4j-api NOP-логирует и не падает).
|
// (binding для JVM; без него slf4j-api NOP-логирует и не падает).
|
||||||
@@ -96,11 +106,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
}
|
|
||||||
|
|
||||||
jvmTest.dependencies {
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
@@ -142,11 +147,11 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
|||||||
// из любого типа, который сейчас прикидывается `configurations`.
|
// из любого типа, который сейчас прикидывается `configurations`.
|
||||||
val cc = try {
|
val cc = try {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
configurations as ConfigurationContainer
|
||||||
} catch (_: ClassCastException) {
|
} catch (_: ClassCastException) {
|
||||||
// KGP-generated dependency accessor; обходим через raw project.
|
// KGP-generated dependency accessor; обходим через raw project.
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
(project as org.gradle.api.Project).configurations as ConfigurationContainer
|
||||||
}
|
}
|
||||||
from(cc.getByName("jvmRuntimeClasspath"))
|
from(cc.getByName("jvmRuntimeClasspath"))
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,10 +10,10 @@ import pw.binom.a2a.model.Message
|
|||||||
import pw.binom.a2a.model.Role
|
import pw.binom.a2a.model.Role
|
||||||
import pw.binom.a2a.model.TextPart
|
import pw.binom.a2a.model.TextPart
|
||||||
import pw.binom.a2a.server.AgentHandler
|
import pw.binom.a2a.server.AgentHandler
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
+17
-26
@@ -9,13 +9,14 @@ import io.ktor.server.routing.post
|
|||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.standalone.agent.ChatConversation
|
import pw.binom.agentik.standalone.agent.ChatConversation
|
||||||
import pw.binom.agentik.llm.tools.LlmReflector
|
import pw.binom.agentik.llm.tools.LlmReflector
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
import pw.binom.agentik.llm.tools.SkillMiner
|
||||||
import pw.binom.agentik.standalone.agent.memory.Curator
|
import pw.binom.agentik.standalone.agent.memory.Curator
|
||||||
import pw.binom.agentik.storage.StorageBundle
|
|
||||||
import pw.binom.agentik.skills.SkillStore
|
import pw.binom.agentik.skills.SkillStore
|
||||||
|
import pw.binom.agentik.context.ContextStore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug-эндпоинты для ручного триггерирования фоновых фич (без ожидания
|
* Debug-эндпоинты для ручного триггерирования фоновых фич (без ожидания
|
||||||
@@ -25,7 +26,10 @@ import pw.binom.agentik.skills.SkillStore
|
|||||||
* - `POST /debug/skill-mine?conversationId=...` — прогон [SkillMiner] прямо сейчас
|
* - `POST /debug/skill-mine?conversationId=...` — прогон [SkillMiner] прямо сейчас
|
||||||
* - `POST /debug/curate` — прогон [Curator.runPass] прямо сейчас
|
* - `POST /debug/curate` — прогон [Curator.runPass] прямо сейчас
|
||||||
* - `POST /debug/compact?conversationId=...` — принудительный compaction
|
* - `POST /debug/compact?conversationId=...` — принудительный compaction
|
||||||
* - `GET /debug/tokens?conversationId=...` — token-статистика диалога из БД
|
*
|
||||||
|
* Token-stats эндпоинт убран вместе с `MessageStore.tokenStats()` (см.
|
||||||
|
* :message-log-api/MessageStore.kt). Сейчас token accounting доступен
|
||||||
|
* только через assistant-сообщения с `TurnTokens` (см. MessageRecord).
|
||||||
*
|
*
|
||||||
* Каждый возвращает JSON с результатом (что сохранил / нашёл / сжал), чтобы в
|
* Каждый возвращает JSON с результатом (что сохранил / нашёл / сжал), чтобы в
|
||||||
* тестах было видно не только "триггер сработал", а что именно LLM намайнила.
|
* тестах было видно не только "триггер сработал", а что именно LLM намайнила.
|
||||||
@@ -33,7 +37,8 @@ import pw.binom.agentik.skills.SkillStore
|
|||||||
*/
|
*/
|
||||||
internal fun Route.debugRoutes(
|
internal fun Route.debugRoutes(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
storage: pw.binom.agentik.storage.StorageBundle,
|
workingMemoryStore: ContextStore,
|
||||||
|
reflectionStore: ReflectionStore,
|
||||||
reflector: LlmReflector?,
|
reflector: LlmReflector?,
|
||||||
skillMiner: SkillMiner?,
|
skillMiner: SkillMiner?,
|
||||||
skillStore: SkillStore?,
|
skillStore: SkillStore?,
|
||||||
@@ -44,14 +49,14 @@ internal fun Route.debugRoutes(
|
|||||||
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
|
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
|
||||||
val minerReflector = reflector
|
val minerReflector = reflector
|
||||||
if (minerReflector == null) return@post call.respondText("reflection disabled", status = HttpStatusCode.NotFound)
|
if (minerReflector == null) return@post call.respondText("reflection disabled", status = HttpStatusCode.NotFound)
|
||||||
val turns = recentTurns(storage, convId, maxTurns = 6)
|
val turns = recentTurns(workingMemoryStore, convId, maxTurns = 6)
|
||||||
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
|
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
|
||||||
val reflection = minerReflector.reflect(turns)
|
val reflection = minerReflector.reflect(turns)
|
||||||
if (reflection == null) {
|
if (reflection == null) {
|
||||||
call.respondText("""{"reflected":false,"reason":"unparseable LLM reply"}""", contentType = ContentType.Application.Json)
|
call.respondText("""{"reflected":false,"reason":"unparseable LLM reply"}""", contentType = ContentType.Application.Json)
|
||||||
} else {
|
} else {
|
||||||
val stamped = reflection.copy(conversationId = convId)
|
val stamped = reflection.copy(conversationId = convId)
|
||||||
storage.reflectionStore.insert(stamped)
|
reflectionStore.insert(stamped)
|
||||||
call.respondText(
|
call.respondText(
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("reflected", true)
|
put("reflected", true)
|
||||||
@@ -71,7 +76,7 @@ internal fun Route.debugRoutes(
|
|||||||
val miner = skillMiner
|
val miner = skillMiner
|
||||||
val store = skillStore
|
val store = skillStore
|
||||||
if (miner == null || store == null) return@post call.respondText("skill mining disabled", status = HttpStatusCode.NotFound)
|
if (miner == null || store == null) return@post call.respondText("skill mining disabled", status = HttpStatusCode.NotFound)
|
||||||
val turns = recentTurns(storage, convId, maxTurns = miner.maxTurns)
|
val turns = recentTurns(workingMemoryStore, convId, maxTurns = miner.maxTurns)
|
||||||
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
|
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
|
||||||
val mined = miner.mine(turns, store.catalog.skills)
|
val mined = miner.mine(turns, store.catalog.skills)
|
||||||
for (s in mined) store.upsert(s)
|
for (s in mined) store.upsert(s)
|
||||||
@@ -106,20 +111,6 @@ internal fun Route.debugRoutes(
|
|||||||
val ok = chatConv.forceCompactNow()
|
val ok = chatConv.forceCompactNow()
|
||||||
call.respondText("""{"compacted":$ok}""", contentType = ContentType.Application.Json)
|
call.respondText("""{"compacted":$ok}""", contentType = ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/debug/tokens") {
|
|
||||||
val convId = call.parameters["conversationId"]
|
|
||||||
?: return@get call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
|
|
||||||
val stats = storage.messageStore.tokenStats(convId)
|
|
||||||
val json = buildJsonObject {
|
|
||||||
put("conversationId", convId)
|
|
||||||
put("turns", stats.turns.toString())
|
|
||||||
put("inputTokens", stats.inputTokens.toString())
|
|
||||||
put("outputTokens", stats.outputTokens.toString())
|
|
||||||
put("totalTokens", (stats.inputTokens + stats.outputTokens).toString())
|
|
||||||
}.toString()
|
|
||||||
call.respondText(json, contentType = ContentType.Application.Json)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,14 +118,14 @@ internal fun Route.debugRoutes(
|
|||||||
* (для debug-триггеров reflector/miner; та же логика, что у хуков
|
* (для debug-триггеров reflector/miner; та же логика, что у хуков
|
||||||
* [ChatConversation]).
|
* [ChatConversation]).
|
||||||
*/
|
*/
|
||||||
internal suspend fun recentTurns(storage: pw.binom.agentik.storage.StorageBundle, conversationId: String, maxTurns: Int): List<ConversationTurn> {
|
internal suspend fun recentTurns(workingMemoryStore: ContextStore, conversationId: String, maxTurns: Int): List<ConversationTurn> {
|
||||||
val rows = storage.workingMemoryStore.list(conversationId)
|
val rows = workingMemoryStore.list(conversationId)
|
||||||
val pairs = mutableListOf<ConversationTurn>()
|
val pairs = mutableListOf<ConversationTurn>()
|
||||||
var pendingUser: String? = null
|
var pendingUser: String? = null
|
||||||
for (row in rows) {
|
for (row in rows) {
|
||||||
when (val e = row.entry) {
|
when (val e = row.entry) {
|
||||||
is pw.binom.agentik.storage.WorkingMemoryEntry.User -> pendingUser = e.content.text()
|
is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = e.content.text()
|
||||||
is pw.binom.agentik.storage.WorkingMemoryEntry.Assistant -> {
|
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> {
|
||||||
val user = pendingUser ?: ""
|
val user = pendingUser ?: ""
|
||||||
pendingUser = null
|
pendingUser = null
|
||||||
pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text())
|
pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text())
|
||||||
@@ -146,5 +137,5 @@ internal suspend fun recentTurns(storage: pw.binom.agentik.storage.StorageBundle
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
|
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
|
||||||
internal fun List<pw.binom.agentik.storage.Content>.text(): String =
|
internal fun List<pw.binom.agentik.journal.Content>.text(): String =
|
||||||
filterIsInstance<pw.binom.agentik.storage.Content.Text>().joinToString("\n") { it.body }
|
filterIsInstance<pw.binom.agentik.journal.Content.Text>().joinToString("\n") { it.body }
|
||||||
+28
-26
@@ -29,7 +29,7 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
|
|||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.ModelDownloader
|
import pw.binom.agentik.standalone.llm.ModelDownloader
|
||||||
import pw.binom.agentik.mcp.bridge.McpRegistry
|
import pw.binom.agentik.mcp.bridge.McpRegistry
|
||||||
import pw.binom.agentik.storage.sqlite.SqliteStores
|
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
import pw.binom.agentik.llm.tools.SkillMiner
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +51,8 @@ import pw.binom.agentik.llm.tools.SkillMiner
|
|||||||
*
|
*
|
||||||
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
|
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
|
||||||
* - AGENTIK_PORT / AGENTIK_DB_PATH
|
* - AGENTIK_PORT / AGENTIK_DB_PATH
|
||||||
|
* - AGENTIK_TOKEN — Bearer-токен для HTTP-фасада /agentik (пусто — авторизация выключена)
|
||||||
|
* - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена)
|
||||||
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
|
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
|
||||||
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
|
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
|
||||||
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
|
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
|
||||||
@@ -196,7 +198,7 @@ private fun runServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val llm = config.llm.createLlm()
|
val llm = config.llm.createLlm()
|
||||||
val storage = SqliteStores.open(dbPath = config.agent.dbPath).asBundle()
|
val sqliteStores = KsqliteStores.open(path = config.agent.dbPath)
|
||||||
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
||||||
|
|
||||||
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
|
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
|
||||||
@@ -315,14 +317,17 @@ private fun runServer() {
|
|||||||
// и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt.
|
// и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt.
|
||||||
val reflector: pw.binom.agentik.llm.tools.LlmReflector? =
|
val reflector: pw.binom.agentik.llm.tools.LlmReflector? =
|
||||||
if (config.reflection.interval > 0) LlmReflector(llm = llm) else null
|
if (config.reflection.interval > 0) LlmReflector(llm = llm) else null
|
||||||
val recentReflections: List<pw.binom.agentik.storage.Reflection> =
|
val recentReflections: List<pw.binom.agentik.reflection.Reflection> =
|
||||||
if (config.reflection.topK > 0) kotlinx.coroutines.runBlocking {
|
if (config.reflection.topK > 0) kotlinx.coroutines.runBlocking {
|
||||||
storage.reflectionStore.listRecent(config.reflection.topK)
|
sqliteStores.reflections.listRecent(config.reflection.topK)
|
||||||
} else emptyList()
|
} else emptyList()
|
||||||
|
|
||||||
val agent = ChatAgent(
|
val agent = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
storage = storage,
|
conversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
llmConfig = config.llm,
|
llmConfig = config.llm,
|
||||||
tools = mcpRegistry.namedTools,
|
tools = mcpRegistry.namedTools,
|
||||||
@@ -353,12 +358,18 @@ private fun runServer() {
|
|||||||
val server = embeddedServer(CIO, port = config.agent.port) {
|
val server = embeddedServer(CIO, port = config.agent.port) {
|
||||||
routing {
|
routing {
|
||||||
get("/health") { call.respondText("ok") }
|
get("/health") { call.respondText("ok") }
|
||||||
agentikAgent(agent, path = "/agentik")
|
agentikAgent(agent, path = "/agentik", token = config.agent.authToken)
|
||||||
a2aAgent(agentName = "agentik", handler = A2aBridge(agent), path = "/a2a")
|
a2aAgent(
|
||||||
|
agentName = "agentik",
|
||||||
|
handler = A2aBridge(agent),
|
||||||
|
path = "/a2a",
|
||||||
|
token = config.agent.a2aToken,
|
||||||
|
)
|
||||||
if (config.debug.endpoints) {
|
if (config.debug.endpoints) {
|
||||||
debugRoutes(
|
debugRoutes(
|
||||||
agent = agent,
|
agent = agent,
|
||||||
storage = storage,
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
skillMiner = skillMiner,
|
skillMiner = skillMiner,
|
||||||
skillStore = skillStore,
|
skillStore = skillStore,
|
||||||
@@ -393,29 +404,20 @@ private fun runServer() {
|
|||||||
if (config.debug.endpoints) {
|
if (config.debug.endpoints) {
|
||||||
println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)")
|
println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)")
|
||||||
}
|
}
|
||||||
// Token stats по существующим диалогам (агрегат на старте — каждая запись
|
// Раньше здесь был агрегат tokenStats() по всем conv'ам при старте. Метод
|
||||||
// парсится из payload_json, ну >100 turns и БД приличная — но в рамках
|
// убран из :message-log-api (MessageStore стал чисто read-only list+listFlow);
|
||||||
// стартапа это терпимо).
|
// см. agentik :message-log-api/MessageStore.kt. Token accounting теперь
|
||||||
val existingConvs = kotlinx.coroutines.runBlocking { storage.conversationStore.list(offset = 0, limit = 1000) }
|
// доступен через assistant-сообщения с TurnTokens (см. MessageRecord.AssistantMessage).
|
||||||
|
val existingConvs = kotlinx.coroutines.runBlocking { sqliteStores.conversations.list(offset = 0, limit = 1000) }
|
||||||
if (existingConvs.isNotEmpty()) {
|
if (existingConvs.isNotEmpty()) {
|
||||||
var totalTurns = 0
|
println(" conversations: ${existingConvs.size} (active)")
|
||||||
var totalIn = 0L
|
|
||||||
var totalOut = 0L
|
|
||||||
for (c in existingConvs) {
|
|
||||||
if (c.isTemporal) continue
|
|
||||||
val s = kotlinx.coroutines.runBlocking { storage.messageStore.tokenStats(c.id) }
|
|
||||||
totalTurns += s.turns
|
|
||||||
totalIn += s.inputTokens
|
|
||||||
totalOut += s.outputTokens
|
|
||||||
}
|
|
||||||
if (totalTurns > 0) {
|
|
||||||
println(" tokens: ${existingConvs.size} convs, $totalTurns turns, in=${totalIn}, out=${totalOut}, total=${totalIn + totalOut}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Runtime.getRuntime().addShutdownHook(Thread {
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
agent.close()
|
agent.close()
|
||||||
mcpRegistry.close()
|
mcpRegistry.close()
|
||||||
storage.close()
|
})
|
||||||
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
|
sqliteStores.close()
|
||||||
llm.close()
|
llm.close()
|
||||||
memorySystem?.close()
|
memorySystem?.close()
|
||||||
})
|
})
|
||||||
+5
-5
@@ -12,10 +12,10 @@ import pw.binom.agentik.memory.ConversationTurn
|
|||||||
import pw.binom.agentik.memory.MemoryReviewer
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.skills.SkillStore
|
import pw.binom.agentik.skills.SkillStore
|
||||||
import pw.binom.agentik.storage.Content
|
import pw.binom.agentik.journal.Content
|
||||||
import pw.binom.agentik.storage.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.storage.WorkingMemoryStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.llm.tools.LlmReflector
|
import pw.binom.agentik.llm.tools.LlmReflector
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
import pw.binom.agentik.llm.tools.SkillMiner
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
@@ -46,7 +46,7 @@ internal data class BackgroundConfig(
|
|||||||
|
|
||||||
internal class BackgroundScheduler(
|
internal class BackgroundScheduler(
|
||||||
private val state: ConversationState,
|
private val state: ConversationState,
|
||||||
private val workingMemory: WorkingMemoryStore,
|
private val workingMemory: ContextStore,
|
||||||
private val config: BackgroundConfig,
|
private val config: BackgroundConfig,
|
||||||
private val backgroundEvents: BackgroundEventBus,
|
private val backgroundEvents: BackgroundEventBus,
|
||||||
) {
|
) {
|
||||||
+95
-35
@@ -1,38 +1,47 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||||
|
import pw.binom.agentik.llm.tools.LlmReflector
|
||||||
|
import pw.binom.agentik.llm.tools.SkillMiner
|
||||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
import pw.binom.agentik.memory.MemoryReviewer
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
import pw.binom.agentik.memory.MemorySystemGuidance
|
import pw.binom.agentik.memory.MemorySystemGuidance
|
||||||
import pw.binom.agentik.proto.Agent as ProtoAgent
|
import pw.binom.agentik.proto.Agent as ProtoAgent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
|
import pw.binom.agentik.proto.Event as ProtoEvent
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
import pw.binom.agentik.skills.renderSystemPromptSection
|
import pw.binom.agentik.skills.renderSystemPromptSection
|
||||||
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.storage.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.storage.Reflection
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
import pw.binom.agentik.journal.Ids
|
||||||
import pw.binom.agentik.storage.StorageBundle
|
import pw.binom.agentik.reflection.Reflection
|
||||||
import pw.binom.agentik.toolsets.EnableToolsetTool
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.toolsets.DisableToolsetTool
|
import pw.binom.agentik.toolsets.DisableToolsetTool
|
||||||
|
import pw.binom.agentik.toolsets.EnableToolsetTool
|
||||||
|
import pw.binom.agentik.toolsets.NamedTool
|
||||||
import pw.binom.agentik.toolsets.SystemPromptToolsetSection
|
import pw.binom.agentik.toolsets.SystemPromptToolsetSection
|
||||||
import pw.binom.agentik.toolsets.ToolsetContribution
|
import pw.binom.agentik.toolsets.ToolsetContribution
|
||||||
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||||
import pw.binom.agentik.toolsets.ToolsetRegistry
|
import pw.binom.agentik.toolsets.ToolsetRegistry
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import kotlin.time.Instant
|
|
||||||
import pw.binom.agentik.llm.tools.LlmReflector
|
|
||||||
import pw.binom.agentik.llm.tools.SkillMiner
|
|
||||||
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
|
|
||||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
|
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
|
||||||
@@ -56,7 +65,10 @@ import pw.binom.agentik.toolsets.NamedTool
|
|||||||
*/
|
*/
|
||||||
class ChatAgent(
|
class ChatAgent(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
private val storage: pw.binom.agentik.storage.StorageBundle,
|
private val conversationStore: ConversationStore,
|
||||||
|
private val messageStore: MutableJournalStore,
|
||||||
|
private val workingMemoryStore: ContextStore,
|
||||||
|
private val reflectionStore: ReflectionStore,
|
||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
private val llmConfig: LlmConfig,
|
private val llmConfig: LlmConfig,
|
||||||
private val tools: List<NamedTool> = emptyList(),
|
private val tools: List<NamedTool> = emptyList(),
|
||||||
@@ -188,25 +200,57 @@ class ChatAgent(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
private val agentEvents = MutableSharedFlow<AgentEvent>(
|
/**
|
||||||
extraBufferCapacity = 64,
|
* Единый канал всех событий агента — bounded tail с auto-TTL.
|
||||||
|
*
|
||||||
|
* Заменил ранее существовавшие два канала:
|
||||||
|
* - `agentEvents: MutableSharedFlow<AgentEvent>` (agent lifecycle)
|
||||||
|
* - per-conv `ConversationEvents._flow: MutableSharedFlow<ProtoEvent>`
|
||||||
|
*
|
||||||
|
* Теперь оба пишут сюда через [MutableOutboxStore.append], а consumer'ы
|
||||||
|
* читают через [EventStore.events]/[conversationEvents]/[agentEvents].
|
||||||
|
*
|
||||||
|
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
|
||||||
|
* или подписке на два отдельных канала.
|
||||||
|
*/
|
||||||
|
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
|
||||||
|
maxMessages = null,
|
||||||
|
ttl = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of [messageStore] для HTTP-фасада в `:server`
|
||||||
|
* (`Route.agentikAgent` → `/journal/...` endpoint'ы).
|
||||||
|
*
|
||||||
|
* [MutableJournalStore] == [MutableJournalStore] (typealias), поэтому
|
||||||
|
* narrowing с мутабельного writer'а на read-only view тривиальна.
|
||||||
|
* Используется [Agent.journal] override'ом из `:proto`.
|
||||||
|
*/
|
||||||
|
override val journal: JournalStore
|
||||||
|
get() = messageStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of [eventStore] для HTTP-фасада в `:server`
|
||||||
|
* (`Route.agentikAgent` → `/outbox/...` endpoint'ы).
|
||||||
|
*
|
||||||
|
* [MutableOutboxStore] == [MutableOutboxStore] (typealias), narrowing тривиальна.
|
||||||
|
* Используется [Agent.outbox] override'ом из `:proto`.
|
||||||
|
*/
|
||||||
|
override val outbox: OutboxStore
|
||||||
|
get() = eventStore
|
||||||
|
|
||||||
/** Защищает карту живых диалогов. */
|
/** Защищает карту живых диалогов. */
|
||||||
private val liveLock = Mutex()
|
private val liveLock = Mutex()
|
||||||
private val live: MutableMap<String, ChatConversation> = HashMap()
|
private val live: MutableMap<String, ChatConversation> = HashMap()
|
||||||
|
|
||||||
/** Live-подписка на события уровня агента (создание/удаление/переименование). */
|
// events()/allEvents() больше НЕ override'ятся — эти методы удалены
|
||||||
override fun events(after: Instant): Flow<AgentEvent> {
|
// из :proto.Agent после миграции событий в outbox-сущность.
|
||||||
// Реализация событийной шины упрощённая: возвращаем общий поток.
|
// Live-подписки теперь идут через Agent.outbox.events() / .agentEvents()
|
||||||
// Фильтр по `after` не делаем — для v1 после-семантика не нужна
|
// (см. README :proto для контракта catchup+live).
|
||||||
// (см. Memory #3704: replay-free, бэкфилл через getConversations/getConversation).
|
|
||||||
return agentEvents.asSharedFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createConversation(temp: Boolean): ProtoConversation {
|
override fun createConversation(temp: Boolean): ProtoConversation {
|
||||||
val now = now()
|
val now = now()
|
||||||
val id = pw.binom.agentik.storage.Ids.new("conv")
|
val id = pw.binom.agentik.journal.Ids.new("conv")
|
||||||
val rec = ConversationRecord(
|
val rec = ConversationRecord(
|
||||||
id = id,
|
id = id,
|
||||||
title = null,
|
title = null,
|
||||||
@@ -218,12 +262,16 @@ class ChatAgent(
|
|||||||
// и не переживают рестарт агента (см. Memory #3709).
|
// и не переживают рестарт агента (см. Memory #3709).
|
||||||
if (!temp) {
|
if (!temp) {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
storage.conversationStore.upsert(rec)
|
conversationStore.upsert(rec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val conv = ChatConversation(
|
val conv = ChatConversation(
|
||||||
record = rec,
|
record = rec,
|
||||||
storage = storage,
|
conversationStore = conversationStore,
|
||||||
|
messageStore = messageStore,
|
||||||
|
workingMemoryStore = workingMemoryStore,
|
||||||
|
reflectionStore = reflectionStore,
|
||||||
|
eventStore = eventStore,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
tools = allTools,
|
tools = allTools,
|
||||||
@@ -234,7 +282,6 @@ class ChatAgent(
|
|||||||
contextWindow = contextWindow,
|
contextWindow = contextWindow,
|
||||||
compressionThreshold = compressionThreshold,
|
compressionThreshold = compressionThreshold,
|
||||||
contextCompactor = contextCompactor,
|
contextCompactor = contextCompactor,
|
||||||
reflectionStore = storage.reflectionStore,
|
|
||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
skillMiner = skillMiner,
|
skillMiner = skillMiner,
|
||||||
skillMiningStore = skillStore,
|
skillMiningStore = skillStore,
|
||||||
@@ -242,13 +289,20 @@ class ChatAgent(
|
|||||||
runBlocking {
|
runBlocking {
|
||||||
liveLock.withLock { live[conv.id] = conv }
|
liveLock.withLock { live[conv.id] = conv }
|
||||||
}
|
}
|
||||||
agentEvents.tryEmit(AgentEvent.Created(date = now(), conversationId = conv.id))
|
runBlocking {
|
||||||
|
eventStore.append(
|
||||||
|
CommonEvent.Agent(
|
||||||
|
date = now(),
|
||||||
|
event = AgentEvent.Created(date = now(), conversationId = conv.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
return conv
|
return conv
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getConversation(id: String): ProtoConversation? {
|
override suspend fun getConversation(id: String): ProtoConversation? {
|
||||||
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
|
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
|
||||||
val rec = storage.conversationStore.get(id) ?: return null
|
val rec = conversationStore.get(id) ?: return null
|
||||||
return newConversation(rec).also {
|
return newConversation(rec).also {
|
||||||
liveLock.withLock { live[id] = it }
|
liveLock.withLock { live[id] = it }
|
||||||
}
|
}
|
||||||
@@ -257,13 +311,16 @@ class ChatAgent(
|
|||||||
override suspend fun deleteConversation(id: String): Boolean {
|
override suspend fun deleteConversation(id: String): Boolean {
|
||||||
val conv = liveLock.withLock { live.remove(id) }
|
val conv = liveLock.withLock { live.remove(id) }
|
||||||
conv?.close()
|
conv?.close()
|
||||||
val ok = storage.conversationStore.delete(id)
|
val ok = conversationStore.delete(id)
|
||||||
if (ok) agentEvents.tryEmit(AgentEvent.Deleted(date = now(), id = id))
|
if (ok) {
|
||||||
|
val event = AgentEvent.Deleted(date = now(), id = id)
|
||||||
|
eventStore.append(CommonEvent.Agent(date = now(), event = event))
|
||||||
|
}
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
|
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
|
||||||
storage.conversationStore.list(offset = offset, limit = limit).map { rec ->
|
conversationStore.list(offset = offset, limit = limit).map { rec ->
|
||||||
liveLock.withLock { live[rec.id] }
|
liveLock.withLock { live[rec.id] }
|
||||||
?: newConversation(rec).also {
|
?: newConversation(rec).also {
|
||||||
liveLock.withLock { live[rec.id] = it }
|
liveLock.withLock { live[rec.id] = it }
|
||||||
@@ -272,7 +329,11 @@ class ChatAgent(
|
|||||||
|
|
||||||
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
|
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
|
||||||
record = rec,
|
record = rec,
|
||||||
storage = storage,
|
conversationStore = conversationStore,
|
||||||
|
messageStore = messageStore,
|
||||||
|
workingMemoryStore = workingMemoryStore,
|
||||||
|
reflectionStore = reflectionStore,
|
||||||
|
eventStore = eventStore,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
tools = allTools,
|
tools = allTools,
|
||||||
@@ -283,7 +344,6 @@ class ChatAgent(
|
|||||||
contextWindow = contextWindow,
|
contextWindow = contextWindow,
|
||||||
compressionThreshold = compressionThreshold,
|
compressionThreshold = compressionThreshold,
|
||||||
contextCompactor = contextCompactor,
|
contextCompactor = contextCompactor,
|
||||||
reflectionStore = storage.reflectionStore,
|
|
||||||
reflector = reflector,
|
reflector = reflector,
|
||||||
skillMiner = skillMiner,
|
skillMiner = skillMiner,
|
||||||
skillMiningStore = skillStore,
|
skillMiningStore = skillStore,
|
||||||
+5
-5
@@ -6,10 +6,10 @@ import pw.binom.agentik.memory.ConversationTurn
|
|||||||
import pw.binom.agentik.memory.MemoryReviewer
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.standalone.agent.memory.materializeReviewNote
|
import pw.binom.agentik.standalone.agent.memory.materializeReviewNote
|
||||||
import pw.binom.agentik.storage.Content
|
import pw.binom.agentik.journal.Content
|
||||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.storage.WorkingMemoryRow
|
import pw.binom.agentik.context.WorkingMemoryRow
|
||||||
import pw.binom.agentik.storage.WorkingMemoryStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.litert.LiteContentPart
|
import pw.binom.litert.LiteContentPart
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
import pw.binom.litert.LiteConversationConfig
|
import pw.binom.litert.LiteConversationConfig
|
||||||
@@ -26,7 +26,7 @@ internal class CompactionCoordinator(
|
|||||||
private val contextCompactor: ContextCompactor?,
|
private val contextCompactor: ContextCompactor?,
|
||||||
private val memoryReviewer: MemoryReviewer?,
|
private val memoryReviewer: MemoryReviewer?,
|
||||||
private val memoryStoreForReview: MemoryStore?,
|
private val memoryStoreForReview: MemoryStore?,
|
||||||
private val workingMemory: WorkingMemoryStore,
|
private val workingMemory: ContextStore,
|
||||||
private val liteLlm: LiteLlm,
|
private val liteLlm: LiteLlm,
|
||||||
private val systemPrompt: String,
|
private val systemPrompt: String,
|
||||||
private val backgroundEvents: BackgroundEventBus,
|
private val backgroundEvents: BackgroundEventBus,
|
||||||
+2
-2
@@ -3,8 +3,8 @@ package pw.binom.agentik.standalone.agent
|
|||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
import pw.binom.litert.LiteContentPart
|
import pw.binom.litert.LiteContentPart
|
||||||
import pw.binom.agentik.storage.MessageContext
|
import pw.binom.agentik.journal.MessageContext
|
||||||
import pw.binom.agentik.storage.MessageOrigin
|
import pw.binom.agentik.journal.MessageOrigin
|
||||||
|
|
||||||
internal class ContextBuilder(
|
internal class ContextBuilder(
|
||||||
private val memoryPrefetcher: MemoryPrefetcher?,
|
private val memoryPrefetcher: MemoryPrefetcher?,
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.proto.Event as ProtoEvent
|
||||||
|
|
||||||
|
internal class ConversationEvents(
|
||||||
|
private val globalEventStore: MutableOutboxStore,
|
||||||
|
private val conversationId: String,
|
||||||
|
) {
|
||||||
|
fun tryEmit(event: ProtoEvent): Boolean {
|
||||||
|
runBlocking {
|
||||||
|
globalEventStore.append(
|
||||||
|
CommonEvent.Conversation(
|
||||||
|
date = event.date,
|
||||||
|
conversationId = conversationId,
|
||||||
|
event = event,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun events(after: kotlin.time.Instant?): Flow<ProtoEvent> =
|
||||||
|
globalEventStore.conversationEvents(after = after, conversationId = conversationId)
|
||||||
|
.map { it.event }
|
||||||
|
}
|
||||||
+47
-39
@@ -24,22 +24,21 @@ import pw.binom.agentik.memory.MemoryReviewer
|
|||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.proto.Content as ProtoContent
|
import pw.binom.agentik.proto.Content as ProtoContent
|
||||||
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
import pw.binom.agentik.proto.Event as ProtoEvent
|
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||||
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.proto.Message as ProtoMessage
|
import pw.binom.agentik.proto.Message as ProtoMessage
|
||||||
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
||||||
import pw.binom.agentik.skills.SkillStore
|
import pw.binom.agentik.skills.SkillStore
|
||||||
import pw.binom.agentik.storage.Content
|
import pw.binom.agentik.journal.Content
|
||||||
import pw.binom.agentik.storage.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.storage.ConversationStore
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
import pw.binom.agentik.storage.MessageContext
|
import pw.binom.agentik.journal.MessageContext
|
||||||
import pw.binom.agentik.storage.MessageOrigin
|
import pw.binom.agentik.journal.MessageOrigin
|
||||||
import pw.binom.agentik.storage.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.storage.MessageStore
|
import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore
|
||||||
import pw.binom.agentik.storage.ReflectionStore
|
import pw.binom.agentik.journal.TurnTokens
|
||||||
import pw.binom.agentik.storage.StorageBundle
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.storage.TurnTokens
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
|
||||||
import pw.binom.agentik.storage.WorkingMemoryStore
|
|
||||||
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||||
import pw.binom.litert.LiteContentPart
|
import pw.binom.litert.LiteContentPart
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
@@ -55,7 +54,11 @@ import pw.binom.agentik.toolsets.NamedTool
|
|||||||
|
|
||||||
class ConversationLoop(
|
class ConversationLoop(
|
||||||
record: ConversationRecord,
|
record: ConversationRecord,
|
||||||
private val storage: StorageBundle,
|
private val conversationStore: ConversationStore,
|
||||||
|
private val messageStore: MutableJournalStore,
|
||||||
|
private val workingMemoryStore: ContextStore,
|
||||||
|
private val reflectionStore: ReflectionStore?,
|
||||||
|
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
|
||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
private val systemPrompt: String,
|
private val systemPrompt: String,
|
||||||
private val tools: List<NamedTool> = emptyList(),
|
private val tools: List<NamedTool> = emptyList(),
|
||||||
@@ -66,7 +69,6 @@ class ConversationLoop(
|
|||||||
private val contextWindow: Int? = null,
|
private val contextWindow: Int? = null,
|
||||||
private val compressionThreshold: Double = 0.8,
|
private val compressionThreshold: Double = 0.8,
|
||||||
private val contextCompactor: ContextCompactor? = null,
|
private val contextCompactor: ContextCompactor? = null,
|
||||||
private val reflectionStore: ReflectionStore? = null,
|
|
||||||
private val reflector: LlmReflector? = null,
|
private val reflector: LlmReflector? = null,
|
||||||
private val skillMiner: SkillMiner? = null,
|
private val skillMiner: SkillMiner? = null,
|
||||||
private val skillMiningStore: SkillStore? = null,
|
private val skillMiningStore: SkillStore? = null,
|
||||||
@@ -84,15 +86,14 @@ class ConversationLoop(
|
|||||||
agentScope = agentScope,
|
agentScope = agentScope,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val events = ConversationEvents()
|
private val events = ConversationEvents(
|
||||||
|
globalEventStore = eventStore,
|
||||||
|
conversationId = state.id,
|
||||||
|
)
|
||||||
|
|
||||||
/** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */
|
/** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */
|
||||||
private val backgroundEvents = BackgroundEventBus()
|
private val backgroundEvents = BackgroundEventBus()
|
||||||
|
|
||||||
private val conversationStore: ConversationStore get() = storage.conversationStore
|
|
||||||
private val messageStore: MessageStore get() = storage.messageStore
|
|
||||||
private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore
|
|
||||||
|
|
||||||
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
|
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
|
||||||
|
|
||||||
private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher)
|
private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher)
|
||||||
@@ -104,7 +105,7 @@ class ConversationLoop(
|
|||||||
contextCompactor = contextCompactor,
|
contextCompactor = contextCompactor,
|
||||||
memoryReviewer = memoryReviewer,
|
memoryReviewer = memoryReviewer,
|
||||||
memoryStoreForReview = memoryStoreForReview,
|
memoryStoreForReview = memoryStoreForReview,
|
||||||
workingMemory = workingMemory,
|
workingMemory = workingMemoryStore,
|
||||||
liteLlm = llm,
|
liteLlm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
backgroundEvents = backgroundEvents,
|
backgroundEvents = backgroundEvents,
|
||||||
@@ -124,7 +125,7 @@ class ConversationLoop(
|
|||||||
|
|
||||||
private val backgroundScheduler = BackgroundScheduler(
|
private val backgroundScheduler = BackgroundScheduler(
|
||||||
state = state,
|
state = state,
|
||||||
workingMemory = workingMemory,
|
workingMemory = workingMemoryStore,
|
||||||
config = BackgroundConfig(
|
config = BackgroundConfig(
|
||||||
memoryReviewer = memoryReviewer,
|
memoryReviewer = memoryReviewer,
|
||||||
memoryStore = memoryStoreForReview,
|
memoryStore = memoryStoreForReview,
|
||||||
@@ -175,7 +176,7 @@ class ConversationLoop(
|
|||||||
|
|
||||||
if (!state.isTemporal) {
|
if (!state.isTemporal) {
|
||||||
messageStore.append(userRecord)
|
messageStore.append(userRecord)
|
||||||
workingMemory.append(
|
workingMemoryStore.append(
|
||||||
conversationId = id,
|
conversationId = id,
|
||||||
entry = WorkingMemoryEntry.User(
|
entry = WorkingMemoryEntry.User(
|
||||||
sourceMessageId = userMessageId,
|
sourceMessageId = userMessageId,
|
||||||
@@ -195,8 +196,23 @@ class ConversationLoop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun interrupt() {
|
override suspend fun interrupt() {
|
||||||
if (activeTurn?.isActive != true) {
|
// Всегда ставим флаг — даже если activeTurn ещё не стартовал.
|
||||||
log.info { "interrupt() no-op: no active turn for $id" }
|
// runTurn проверяет interrupted.get() при входе (short-circuit) и в
|
||||||
|
// каждой итерации цикла + finally. Если turn запустится ПОСЛЕ нашего
|
||||||
|
// interrupt() — он увидит флаг на entry и сразу завершится без
|
||||||
|
// реального LLM-вызова. Если turn уже идёт — Interrupted + End придут
|
||||||
|
// в finally.
|
||||||
|
//
|
||||||
|
// Раньше здесь был early-return при `!activeTurn?.isActive`, но это
|
||||||
|
// давало race с точки зрения тестов: send() может завершиться
|
||||||
|
// быстрее (например, на ksqlite-бэкенде, где messageStore.append
|
||||||
|
// практически мгновенный), и interrupt(), вызванный после
|
||||||
|
// delay(200) от launch send(), видел completed Job → no-op →
|
||||||
|
// Interrupted event не эмитится.
|
||||||
|
val wasActive = activeTurn?.isActive
|
||||||
|
if (wasActive != null && !wasActive) {
|
||||||
|
// Предыдущий turn уже завершился — interrupt() действительно no-op.
|
||||||
|
log.info { "interrupt() no-op: previous turn already completed for $id" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
interrupted.set(true)
|
interrupted.set(true)
|
||||||
@@ -205,7 +221,7 @@ class ConversationLoop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<ProtoEvent> =
|
override fun events(after: Instant): Flow<ProtoEvent> =
|
||||||
events.flow
|
events.events(after)
|
||||||
|
|
||||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<ProtoMessage> =
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<ProtoMessage> =
|
||||||
messageStore.list(conversationId = id, after = after, offset = offset, limit = limit)
|
messageStore.list(conversationId = id, after = after, offset = offset, limit = limit)
|
||||||
@@ -408,7 +424,7 @@ class ConversationLoop(
|
|||||||
)
|
)
|
||||||
messageStore.append(assistantRecord)
|
messageStore.append(assistantRecord)
|
||||||
|
|
||||||
workingMemory.append(
|
workingMemoryStore.append(
|
||||||
conversationId = id,
|
conversationId = id,
|
||||||
entry = WorkingMemoryEntry.Assistant(
|
entry = WorkingMemoryEntry.Assistant(
|
||||||
sourceMessageId = assistantId,
|
sourceMessageId = assistantId,
|
||||||
@@ -418,7 +434,7 @@ class ConversationLoop(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (ex in toolExchanges) {
|
for (ex in toolExchanges) {
|
||||||
workingMemory.append(
|
workingMemoryStore.append(
|
||||||
conversationId = id,
|
conversationId = id,
|
||||||
entry = ex,
|
entry = ex,
|
||||||
now = assistantAt,
|
now = assistantAt,
|
||||||
@@ -466,7 +482,7 @@ class ConversationLoop(
|
|||||||
private fun now(): Instant =
|
private fun now(): Instant =
|
||||||
Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||||
|
|
||||||
private fun newId(prefix: String): String = pw.binom.agentik.storage.Ids.new(prefix)
|
private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix)
|
||||||
|
|
||||||
private fun encodeArgsJson(arguments: Map<String, Any?>): String {
|
private fun encodeArgsJson(arguments: Map<String, Any?>): String {
|
||||||
val el = JsonElement.serializer()
|
val el = JsonElement.serializer()
|
||||||
@@ -560,16 +576,8 @@ internal fun MessageRecord.toProto(): ProtoMessage = when (this) {
|
|||||||
message = message,
|
message = message,
|
||||||
code = code,
|
code = code,
|
||||||
)
|
)
|
||||||
is MessageRecord.Summary -> ProtoMessage.AssistantMessage(
|
// MessageRecord sealed — все варианты покрыты выше (User/Assistant/ToolCall/ToolResult/Error).
|
||||||
id = id,
|
// Summary/System из :working-memory НЕ попадают в audit log (:message-log-api).
|
||||||
date = createdAt,
|
|
||||||
content = listOf(ProtoContent.Text(body = text)),
|
|
||||||
)
|
|
||||||
is MessageRecord.System -> ProtoMessage.UserMessage(
|
|
||||||
id = id,
|
|
||||||
date = createdAt,
|
|
||||||
content = listOf(ProtoContent.Text(body = text)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readTokenCount(liteConv: LiteConversation): Int? = try {
|
private fun readTokenCount(liteConv: LiteConversation): Int? = try {
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import pw.binom.agentik.storage.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
import pw.binom.agentik.toolsets.NamedTool
|
||||||
+5
-5
@@ -4,10 +4,10 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import pw.binom.agentik.proto.Event as ProtoEvent
|
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||||
import pw.binom.agentik.storage.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.storage.MessageStore
|
import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore
|
||||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||||
import pw.binom.litert.LiteToolCall
|
import pw.binom.litert.LiteToolCall
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
@@ -16,7 +16,7 @@ import pw.binom.agentik.toolsets.NamedTool
|
|||||||
|
|
||||||
internal class ToolDispatcher(
|
internal class ToolDispatcher(
|
||||||
private val state: ConversationState,
|
private val state: ConversationState,
|
||||||
private val messageStore: MessageStore,
|
private val messageStore: MutableJournalStore,
|
||||||
private val events: ConversationEvents,
|
private val events: ConversationEvents,
|
||||||
private val backgroundEvents: BackgroundEventBus,
|
private val backgroundEvents: BackgroundEventBus,
|
||||||
private val toolsByName: MutableMap<String, NamedTool>,
|
private val toolsByName: MutableMap<String, NamedTool>,
|
||||||
+14
@@ -50,6 +50,18 @@ data class AppConfig(
|
|||||||
* `null` — файл не читается, секция не добавляется.
|
* `null` — файл не читается, секция не добавляется.
|
||||||
*/
|
*/
|
||||||
val soulPath: String? = null,
|
val soulPath: String? = null,
|
||||||
|
/**
|
||||||
|
* Токен для HTTP-фасада agentik (`/agentik`). `null` — авторизация выключена,
|
||||||
|
* сервер открыт (поведение по умолчанию, обратная совместимость).
|
||||||
|
* Источник: env `AGENTIK_TOKEN`.
|
||||||
|
*/
|
||||||
|
val authToken: String? = null,
|
||||||
|
/**
|
||||||
|
* Токен для A2A-фасада (`/a2a`). НЕ связан с [authToken] — это разные
|
||||||
|
* интерфейсы и разные токены (см. библиотеку pw.binom.a2a).
|
||||||
|
* `null` — авторизация выключена. Источник: env `AGENTIK_A2A_TOKEN`.
|
||||||
|
*/
|
||||||
|
val a2aToken: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Долговременная память. */
|
/** Долговременная память. */
|
||||||
@@ -163,6 +175,8 @@ data class AppConfig(
|
|||||||
dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH,
|
dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH,
|
||||||
skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() },
|
skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() },
|
||||||
soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() },
|
soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() },
|
||||||
|
authToken = env("AGENTIK_TOKEN")?.takeIf { it.isNotBlank() },
|
||||||
|
a2aToken = env("AGENTIK_A2A_TOKEN")?.takeIf { it.isNotBlank() },
|
||||||
),
|
),
|
||||||
llm = llm,
|
llm = llm,
|
||||||
mcp = McpConfig.fromEnv(env),
|
mcp = McpConfig.fromEnv(env),
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import pw.binom.agentik.proto.Event as ProtoEvent
|
|
||||||
|
|
||||||
internal class ConversationEvents {
|
|
||||||
private val _flow = MutableSharedFlow<ProtoEvent>(
|
|
||||||
replay = 0,
|
|
||||||
extraBufferCapacity = 4096,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
val flow: SharedFlow<ProtoEvent> get() = _flow.asSharedFlow()
|
|
||||||
|
|
||||||
fun tryEmit(event: ProtoEvent): Boolean = _flow.tryEmit(event)
|
|
||||||
|
|
||||||
suspend fun emit(event: ProtoEvent) = _flow.emit(event)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user