Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 059c23e3ab | |||
| bd7e079780 | |||
| 509763cf36 | |||
| 4156e5f95f | |||
| f191387e99 | |||
| d1b4f897b7 |
@@ -30,3 +30,8 @@ agentik.db
|
|||||||
agentik.db-shm
|
agentik.db-shm
|
||||||
agentik.db-wal
|
agentik.db-wal
|
||||||
memory-md/agentik-mem-*/
|
memory-md/agentik-mem-*/
|
||||||
|
|
||||||
|
# Runtime-данные standalone-агента (db/memory/skills при локальном запуске)
|
||||||
|
/standalone/agentik/
|
||||||
|
hs_err_pid*.log
|
||||||
|
core.*
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import kotlinx.cli.default
|
|||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
|
|||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.cli.vararg
|
import kotlinx.cli.vararg
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.takeWhile
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -10,7 +11,8 @@ import pw.binom.agentik.cli.AgentikSubcommand
|
|||||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.outbox.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||||
@@ -24,8 +26,8 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
return@runBlocking
|
return@runBlocking
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Подписываемся на поток событий ДО send: события, отправленные
|
// Durable-поток (End/Interrupted/Error + Tool*) — ловит терминатор хода.
|
||||||
// до подписки, не реплеятся (shared-flow без replay).
|
// Подписываемся ДО send: события, отправленные до подписки, не реплеятся.
|
||||||
val eventsJob = launch {
|
val eventsJob = launch {
|
||||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||||
// onEach печатает и терминальный event, takeWhile лишь
|
// onEach печатает и терминальный event, takeWhile лишь
|
||||||
@@ -35,10 +37,19 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
.takeWhile { ev -> !isTerminal(ev) }
|
.takeWhile { ev -> !isTerminal(ev) }
|
||||||
.collect { }
|
.collect { }
|
||||||
}
|
}
|
||||||
// Даём SSE-подписке установиться, затем шлём ход.
|
// Онлайн-поток (дельты стриминга ответа) — live-only, без терминатора.
|
||||||
|
val onlineJob = launch {
|
||||||
|
agent.onlineOutbox.onlineEvents(conv.id)
|
||||||
|
.onEach { ev -> emitOnline(ev) }
|
||||||
|
.collect { }
|
||||||
|
}
|
||||||
|
// Даём SSE-подпискам установиться, затем шлём ход.
|
||||||
delay(200)
|
delay(200)
|
||||||
conv.send(listOf(Content.Text(text.joinToString(" "))))
|
conv.send(listOf(Content.Text(text.joinToString(" "))))
|
||||||
eventsJob.join()
|
eventsJob.join()
|
||||||
|
// Даём онлайн-потоку дослать хвостовые дельты, эмитнутые до End.
|
||||||
|
delay(100)
|
||||||
|
onlineJob.cancel()
|
||||||
} finally {
|
} finally {
|
||||||
conv.close()
|
conv.close()
|
||||||
}
|
}
|
||||||
@@ -49,10 +60,6 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
|
|
||||||
private fun emit(ev: Event) {
|
private fun emit(ev: Event) {
|
||||||
when (ev) {
|
when (ev) {
|
||||||
is Event.StartReasoning -> println("event StartReasoning")
|
|
||||||
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
|
|
||||||
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
|
||||||
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
|
||||||
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||||
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
|
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
|
||||||
is Event.End -> println("event End")
|
is Event.End -> println("event End")
|
||||||
@@ -61,5 +68,14 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun emitOnline(ev: OnlineEvent) {
|
||||||
|
when (ev) {
|
||||||
|
is OnlineEvent.StartReasoning -> println("event StartReasoning")
|
||||||
|
is OnlineEvent.StartResponse -> println("event StartResponse ${ev.responseType}")
|
||||||
|
is OnlineEvent.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||||
|
is OnlineEvent.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
|
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.outbox.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ internal class TuiBackend(
|
|||||||
/** Активная джоба подписки на [Conversation.events]. */
|
/** Активная джоба подписки на [Conversation.events]. */
|
||||||
private var eventsJob: Job? = null
|
private var eventsJob: Job? = null
|
||||||
|
|
||||||
|
/** Активная джоба подписки на онлайн-поток (стриминг ответа). */
|
||||||
|
private var onlineJob: Job? = null
|
||||||
|
|
||||||
/** Последний виденный момент событий — для переподписки при reconnect. */
|
/** Последний виденный момент событий — для переподписки при reconnect. */
|
||||||
private var lastSeenAt: Instant = Instant.DISTANT_PAST
|
private var lastSeenAt: Instant = Instant.DISTANT_PAST
|
||||||
|
|
||||||
@@ -92,41 +96,42 @@ internal class TuiBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
|
* Подписывается на durable-поток `outbox.conversationEvents(after, conv.id)`
|
||||||
|
* и live-поток `onlineOutbox.onlineEvents(conv.id)`; оба перенаправляет в [state].
|
||||||
|
*
|
||||||
|
* Онлайн-поток live-only (без catchup), поэтому подписку открываем ДО [Conversation.send]
|
||||||
|
* (см. [ensureConversation] → [onUserMessage]), чтобы не упустить начало хода.
|
||||||
*/
|
*/
|
||||||
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||||
eventsJob?.cancel()
|
eventsJob?.cancel()
|
||||||
eventsJob = scope.launch {
|
eventsJob = scope.launch {
|
||||||
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
|
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
|
||||||
}
|
}
|
||||||
|
onlineJob?.cancel()
|
||||||
|
onlineJob = scope.launch {
|
||||||
|
agent.onlineOutbox.onlineEvents(conv.id).collect { ev -> dispatchOnline(ev) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Маппинг [Event] → [AppState] (что показать в TUI).
|
* Маппинг [Event] (durable) → [AppState] (что показать в TUI).
|
||||||
*
|
*
|
||||||
* - AppendText → дописывает в последний ассистентский чанк
|
|
||||||
* - StartReasoning / StartResponse → новый streaming-чанк
|
|
||||||
* - End → закрывает streaming
|
* - End → закрывает streaming
|
||||||
* - Interrupted → закрывает streaming + системное сообщение
|
* - Interrupted → закрывает streaming + системное сообщение
|
||||||
* - ToolCall / ToolResult → сообщения в историю
|
* - ToolCall / ToolResult → сообщения в историю
|
||||||
* - Error → системное сообщение
|
* - Error → системное сообщение
|
||||||
|
*
|
||||||
|
* Стриминг ответа (дельты текста/картинок) приходит отдельным потоком —
|
||||||
|
* см. [dispatchOnline].
|
||||||
*/
|
*/
|
||||||
private fun dispatch(ev: Event) {
|
private fun dispatch(ev: Event) {
|
||||||
lastSeenAt = ev.date
|
lastSeenAt = ev.date
|
||||||
when (ev) {
|
when (ev) {
|
||||||
is Event.AppendText -> state.appendAssistant(ev.body)
|
|
||||||
is Event.StartReasoning -> {
|
|
||||||
state.postSystem("… думаю")
|
|
||||||
}
|
|
||||||
is Event.StartResponse -> state.setStreaming(true)
|
|
||||||
is Event.End -> state.finishAssistant()
|
is Event.End -> state.finishAssistant()
|
||||||
is Event.Interrupted -> {
|
is Event.Interrupted -> {
|
||||||
state.finishAssistant()
|
state.finishAssistant()
|
||||||
state.postSystem("прервано")
|
state.postSystem("прервано")
|
||||||
}
|
}
|
||||||
is Event.AppendImage -> {
|
|
||||||
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
|
|
||||||
}
|
|
||||||
is Event.ToolCall -> {
|
is Event.ToolCall -> {
|
||||||
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
|
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
|
||||||
}
|
}
|
||||||
@@ -139,4 +144,20 @@ internal class TuiBackend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг [OnlineEvent] (стриминг ответа, live-only) → [AppState].
|
||||||
|
*
|
||||||
|
* - AppendText → дописывает в последний ассистентский чанк
|
||||||
|
* - StartReasoning / StartResponse → новый streaming-чанк
|
||||||
|
* - AppendImage → системное сообщение-заглушка
|
||||||
|
*/
|
||||||
|
private fun dispatchOnline(ev: OnlineEvent) {
|
||||||
|
when (ev) {
|
||||||
|
is OnlineEvent.AppendText -> state.appendAssistant(ev.body)
|
||||||
|
is OnlineEvent.StartReasoning -> state.postSystem("… думаю")
|
||||||
|
is OnlineEvent.StartResponse -> state.setStreaming(true)
|
||||||
|
is OnlineEvent.AppendImage -> state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import pw.binom.agentik.journal.ConversationStore
|
|||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.outbox.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,7 @@ internal class FakeAgent(
|
|||||||
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
|
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
|
||||||
) : Agent {
|
) : Agent {
|
||||||
override val id: String = "fake"
|
override val id: String = "fake"
|
||||||
|
override val info: AgentInfo = AgentInfo(name = "fake")
|
||||||
var createCount: Int = 0
|
var createCount: Int = 0
|
||||||
private set
|
private set
|
||||||
val conversations = mutableListOf<FakeConversation>()
|
val conversations = mutableListOf<FakeConversation>()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package pw.binom.agentik.tui
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.outbox.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").t
|
|||||||
// beforeEvaluate — поздно).
|
// beforeEvaluate — поздно).
|
||||||
val moduleDescriptions: Map<String, String> = mapOf(
|
val moduleDescriptions: Map<String, String> = mapOf(
|
||||||
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
|
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
|
||||||
|
"content-api" to "agentik :content-api — общие типы содержимого сообщения (Content/MessageContext/MessageOrigin/TurnTokens) для :proto, :journal-api, :outbox-api.",
|
||||||
|
"outbox-api" to "agentik :outbox-api — durable (Event) и live-only (OnlineEvent) потоки событий диалога + OutboxStore/OnlineOutbox.",
|
||||||
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
|
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
|
||||||
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
|
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
|
||||||
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
|
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
|
||||||
|
|||||||
+81
-37
@@ -72,9 +72,11 @@ dependencies {
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@@ -87,21 +89,37 @@ fun main() = runBlocking {
|
|||||||
token = "s3cret", // или null, если не нужен
|
token = "s3cret", // или null, если не нужен
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Открыть диалог, отправить сообщение.
|
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
conv.send(listOf(Content.Text("Привет")))
|
|
||||||
|
|
||||||
// 3. Собирать streaming-ответ.
|
// 2. Два независимых потока событий диалога:
|
||||||
conv.events(after = Clock.System.now()).collect { ev ->
|
// durable (outbox) — целые события, с курсором после переподключения;
|
||||||
when (ev) {
|
// online (OnlineOutbox) — стриминг ответа, только live (без курсора).
|
||||||
is Event.StartResponse -> println("[start]")
|
launch {
|
||||||
is Event.AppendText -> print(ev.body)
|
agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id)
|
||||||
is Event.End -> println("[end]")
|
.collect { ce ->
|
||||||
is Event.Error -> println("[error: ${ev.message}]")
|
when (val ev = ce.event) {
|
||||||
else -> Unit
|
is Event.AssistantMessage -> println("[answer ready: ${ev.content}]")
|
||||||
|
is Event.Interrupted -> println("[interrupted]")
|
||||||
|
is Event.Error -> println("[error: ${ev.message}]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is OnlineEvent.Working -> println("[working]")
|
||||||
|
is OnlineEvent.AppendText -> print(ev.body)
|
||||||
|
is OnlineEvent.StartResponse -> println("[start]")
|
||||||
|
is OnlineEvent.End -> println("\n[end]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Отправить ход (fire-and-forget — ответ придёт по подпискам выше).
|
||||||
|
conv.send(listOf(Content.Text("Привет")))
|
||||||
|
|
||||||
// 4. Чистый shutdown.
|
// 4. Чистый shutdown.
|
||||||
conv.close()
|
conv.close()
|
||||||
agent.close()
|
agent.close()
|
||||||
@@ -109,7 +127,14 @@ fun main() = runBlocking {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||||
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
|
Ты только получаешь два типизированных `Flow` и рендеришь как хочешь.
|
||||||
|
|
||||||
|
> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их
|
||||||
|
> можно перезапросить по курсору `after`. `OnlineEvent` (в
|
||||||
|
> `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/
|
||||||
|
> `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при
|
||||||
|
> обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-
|
||||||
|
> `Event.AssistantMessage` и/или ляжет в journal.
|
||||||
|
|
||||||
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
||||||
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||||
@@ -172,9 +197,11 @@ history.forEach { rec ->
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
val agent = AgentikAgent(
|
val agent = AgentikAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
@@ -183,16 +210,30 @@ val agent = AgentikAgent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
|
||||||
|
|
||||||
conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
// durable-поток (с курсором): terminal-события хода.
|
||||||
when (ev) {
|
launch {
|
||||||
is Event.AppendText -> print(ev.body) // streaming чанки
|
agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id)
|
||||||
is Event.End -> println("\n--- end ---")
|
.collect { ce ->
|
||||||
is Event.Error -> error("agent error: ${ev.message}")
|
when (ce.event) {
|
||||||
else -> Unit
|
is Event.AssistantMessage -> println("\n--- answer ready ---")
|
||||||
|
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// online-поток (live-only): стриминг ответа.
|
||||||
|
launch {
|
||||||
|
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is OnlineEvent.AppendText -> print(ev.body) // streaming чанки
|
||||||
|
is OnlineEvent.End -> println("\n--- end ---")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||||
```
|
```
|
||||||
|
|
||||||
## История с локальным кэшем
|
## История с локальным кэшем
|
||||||
@@ -207,8 +248,8 @@ conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class ChatSession(
|
class ChatSession(
|
||||||
@@ -235,10 +276,11 @@ class ChatSession(
|
|||||||
after = Instant.DISTANT_PAST,
|
after = Instant.DISTANT_PAST,
|
||||||
).collect { cache.append(it) }
|
).collect { cache.append(it) }
|
||||||
}
|
}
|
||||||
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
// 2. Live: на каждом завершённом ходе (durable AssistantMessage)
|
||||||
|
// просим у сервера новые записи.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce ->
|
||||||
if (ev is Event.End) {
|
if (ce.event is Event.AssistantMessage) {
|
||||||
val newest = cache.let {
|
val newest = cache.let {
|
||||||
// last-seen курсор — последний createdAt в кэше
|
// last-seen курсор — последний createdAt в кэше
|
||||||
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||||
@@ -342,22 +384,24 @@ escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступе
|
|||||||
появится в кэше через refresh-блок выше.
|
появится в кэше через refresh-блок выше.
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
|
||||||
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
agent.onlineOutbox.onlineEvents(convId).collect { ev ->
|
||||||
when (ev) {
|
when (ev) {
|
||||||
is Event.StartResponse -> println("[start]")
|
is OnlineEvent.Working -> println("[working]")
|
||||||
is Event.AppendText -> print(ev.body)
|
is OnlineEvent.StartResponse -> println("[start]")
|
||||||
is Event.AppendImage -> showImage(ev.body)
|
is OnlineEvent.AppendText -> print(ev.body)
|
||||||
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
|
is OnlineEvent.AppendImage -> showImage(ev.body)
|
||||||
is Event.ToolResult -> println("[result]")
|
is OnlineEvent.End -> println("[end]")
|
||||||
is Event.End -> println("[end]")
|
else -> Unit
|
||||||
is Event.Error -> println("[error: ${ev.message}]")
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Инструментальные вызовы и целый ответ — durable-поток
|
||||||
|
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и
|
||||||
|
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
|
||||||
|
|
||||||
## Прерывание хода
|
## Прерывание хода
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.server.sse)
|
implementation(libs.ktor.server.sse)
|
||||||
}
|
}
|
||||||
jvmTest.dependencies {
|
jvmTest.dependencies {
|
||||||
implementation("junit:junit:4.13.2")
|
implementation(libs.junit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import io.ktor.http.contentType
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.agentik.journal.ConversationStore
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -27,9 +29,16 @@ import kotlin.time.Instant
|
|||||||
* **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
|
* **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
|
||||||
* views на серверные хранилища. Запись — только через команды
|
* views на серверные хранилища. Запись — только через команды
|
||||||
* [createConversation] / [deleteConversation] / [renameConversation].
|
* [createConversation] / [deleteConversation] / [renameConversation].
|
||||||
|
*
|
||||||
|
* Конструируется через suspend [Companion.create], который **eagerly**
|
||||||
|
* фетчит [info] (`GET {baseUrl}`) и сохраняет снимок в поле. Это убирает
|
||||||
|
* необходимость в `lazy { runBlocking { ... } }` на горячем пути —
|
||||||
|
* `runBlocking` живёт один раз в [Companion.create], оттуда же [AgentikAgent]
|
||||||
|
* его и вызывает (там он приемлем: одноразовая инициализация агента).
|
||||||
*/
|
*/
|
||||||
internal class AgentClient(
|
internal class AgentClient private constructor(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
|
override val info: AgentInfo,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
private val httpClient: HttpClient,
|
private val httpClient: HttpClient,
|
||||||
) : Agent {
|
) : Agent {
|
||||||
@@ -37,6 +46,7 @@ internal class AgentClient(
|
|||||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
|
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
override val onlineOutbox: OnlineOutbox = HttpOnlineOutbox(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
|
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
|
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
|
||||||
@@ -74,4 +84,32 @@ internal class AgentClient(
|
|||||||
override fun close() {
|
override fun close() {
|
||||||
httpClient.close()
|
httpClient.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Создаёт [AgentClient] и eagerly загружает [Agent.info]
|
||||||
|
* (`GET {baseUrl}` на серверном фасаде). Любой сбой сети на этом
|
||||||
|
* этапе пробрасывается как исключение — агент без `info` бесполезен
|
||||||
|
* (UI/A2A сразу упрутся в `agent.info`).
|
||||||
|
*
|
||||||
|
* Single-shot инициализация, `runBlocking` тут допустим (см. KDoc
|
||||||
|
* класса). Хосты, которым нужен полностью неблокирующий старт,
|
||||||
|
* могут обернуть вызов в свой `CoroutineScope`.
|
||||||
|
*/
|
||||||
|
suspend fun create(
|
||||||
|
id: String,
|
||||||
|
baseUrl: String,
|
||||||
|
httpClient: HttpClient,
|
||||||
|
): AgentClient {
|
||||||
|
val agentUrl = baseUrl.trimEnd('/')
|
||||||
|
val info: AgentInfo = httpClient.get(agentUrl).body()
|
||||||
|
return AgentClient(
|
||||||
|
id = id,
|
||||||
|
info = info,
|
||||||
|
baseUrl = agentUrl,
|
||||||
|
httpClient = httpClient,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
@@ -95,7 +96,7 @@ fun AgentikAgent(
|
|||||||
token: String? = null,
|
token: String? = null,
|
||||||
): Agent {
|
): Agent {
|
||||||
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
|
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
|
||||||
val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient)
|
val client = runBlocking { AgentClient.create(id = id, baseUrl = baseUrl, httpClient = httpClient) }
|
||||||
return wrapWithLocalConversationCache(client, scopeClient = client)
|
return wrapWithLocalConversationCache(client, scopeClient = client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import io.ktor.client.request.setBody
|
|||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +59,9 @@ internal class ConversationClient(
|
|||||||
httpClient.post("$convUrl/messages") {
|
httpClient.post("$convUrl/messages") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(SendPayload(content, context))
|
setBody(SendPayload(content, context))
|
||||||
|
// Сервер отвечает только по завершении хода агента (LLM + тулы),
|
||||||
|
// а это минуты, а не 15 секунд дефолтного request-timeout.
|
||||||
|
noReadTimeout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ internal class HttpConversationStore(
|
|||||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
override suspend fun get(id: String): ConversationRecord? {
|
override suspend fun get(id: String): ConversationRecord? {
|
||||||
val response = httpClient.get("$agentUrl/conversations/$id")
|
// `/record` (а НЕ `/conversations/{id}`): последний отдаёт
|
||||||
|
// ConversationSnapshot для `AgentClient.getConversation`, у которого
|
||||||
|
// другой shape (handle + isImageSupported, без createdAt/updatedAt).
|
||||||
|
val response = httpClient.get("$agentUrl/conversations/$id/record")
|
||||||
if (response.status == HttpStatusCode.NotFound) return null
|
if (response.status == HttpStatusCode.NotFound) return null
|
||||||
check(response.status == HttpStatusCode.OK) {
|
check(response.status == HttpStatusCode.OK) {
|
||||||
"conversationStore.get($id): server returned ${response.status}"
|
"conversationStore.get($id): server returned ${response.status}"
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ internal class HttpEventStore(
|
|||||||
append("$agentUrl/outbox/events")
|
append("$agentUrl/outbox/events")
|
||||||
if (after != null) append("?after=$after")
|
if (after != null) append("?after=$after")
|
||||||
}
|
}
|
||||||
httpClient.prepareGet(url) { noSseReadTimeout() }
|
httpClient.prepareGet(url) { noReadTimeout() }
|
||||||
.execute { response ->
|
.execute { response ->
|
||||||
check(response.status == HttpStatusCode.OK) {
|
check(response.status == HttpStatusCode.OK) {
|
||||||
"events: server returned ${response.status}"
|
"events: server returned ${response.status}"
|
||||||
@@ -74,7 +74,7 @@ internal class HttpEventStore(
|
|||||||
append("$agentUrl/events")
|
append("$agentUrl/events")
|
||||||
if (after != null) append("?after=$after")
|
if (after != null) append("?after=$after")
|
||||||
}
|
}
|
||||||
httpClient.prepareGet(url) { noSseReadTimeout() }
|
httpClient.prepareGet(url) { noReadTimeout() }
|
||||||
.execute { response ->
|
.execute { response ->
|
||||||
check(response.status == HttpStatusCode.OK) {
|
check(response.status == HttpStatusCode.OK) {
|
||||||
"agentEvents: server returned ${response.status}"
|
"agentEvents: server returned ${response.status}"
|
||||||
@@ -104,7 +104,7 @@ internal class HttpEventStore(
|
|||||||
append("$agentUrl/conversations/$conversationId/events")
|
append("$agentUrl/conversations/$conversationId/events")
|
||||||
if (after != null) append("?after=$after")
|
if (after != null) append("?after=$after")
|
||||||
}
|
}
|
||||||
httpClient.prepareGet(url) { noSseReadTimeout() }
|
httpClient.prepareGet(url) { noReadTimeout() }
|
||||||
.execute { response ->
|
.execute { response ->
|
||||||
check(response.status == HttpStatusCode.OK) {
|
check(response.status == HttpStatusCode.OK) {
|
||||||
"conversationEvents: server returned ${response.status}"
|
"conversationEvents: server returned ${response.status}"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import io.ktor.client.call.body
|
|||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -13,8 +14,11 @@ import kotlin.time.Instant
|
|||||||
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
|
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
|
||||||
* ходящая в `:server`-фасад.
|
* ходящая в `:server`-фасад.
|
||||||
*
|
*
|
||||||
* **Endpoint**: `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
|
* **Endpoints** (см. [pw.binom.agentik.server.journalRoutes]):
|
||||||
* (см. [pw.binom.agentik.server.journalRoutes]).
|
* - `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
|
||||||
|
* → [list]
|
||||||
|
* - `GET {baseUrl}/journal/conversations/{id}/count` → [count] (total)
|
||||||
|
* - `GET {baseUrl}/journal/conversations/{id}/count?after=` → [count] (after cursor)
|
||||||
*
|
*
|
||||||
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
|
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
|
||||||
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
|
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
|
||||||
@@ -54,7 +58,28 @@ internal class HttpJournalStore(
|
|||||||
return response.body<List<MessageRecord>>()
|
return response.body<List<MessageRecord>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String): Long {
|
||||||
|
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count")
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"journal.count: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<CountResponse>().count
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String, after: Instant): Long {
|
||||||
|
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") {
|
||||||
|
parameter("after", after.toString())
|
||||||
|
}
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"journal.count(after): server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<CountResponse>().count
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CountResponse(val count: Long)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [OnlineOutbox] (= [pw.binom.agentik.outbox.OnlineOutbox]),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoints**:
|
||||||
|
* - [onlineEvents] (без аргумента) → `GET {baseUrl}/online` (live-only SSE,
|
||||||
|
* все диалоги агента);
|
||||||
|
* - [onlineEvents] с `conversationId` → `GET {baseUrl}/conversations/{id}/online`
|
||||||
|
* (live-only SSE, один диалог).
|
||||||
|
*
|
||||||
|
* Сервер не реплеит — подписка получает только то, что эмитится после
|
||||||
|
* подключения. Reconnect-логика здесь не нужна: при обрыве поток просто
|
||||||
|
* закрывается, а потерянные дельты восстанавливаются из durable-истории
|
||||||
|
* ([HttpEventStore] + журнал).
|
||||||
|
*/
|
||||||
|
internal class HttpOnlineOutbox(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : OnlineOutbox {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override fun onlineEvents(): Flow<OnlineEvent> = stream("$agentUrl/online")
|
||||||
|
|
||||||
|
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
|
||||||
|
stream("$agentUrl/conversations/$conversationId/online")
|
||||||
|
|
||||||
|
private fun stream(url: String): Flow<OnlineEvent> = flow {
|
||||||
|
httpClient.prepareGet(url) { noReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"onlineEvents: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(OnlineEvent.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,18 +8,23 @@ import io.ktor.client.request.HttpRequestBuilder
|
|||||||
* Отключает request/connect/socket-таймауты для конкретного запроса через
|
* Отключает request/connect/socket-таймауты для конкретного запроса через
|
||||||
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
|
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
|
||||||
*
|
*
|
||||||
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
|
* Зачем: два вида запросов живут дольше дефолтных 15 секунд:
|
||||||
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
* - **SSE-чтение** ([readSse]) — читает `bodyAsChannel()` руками и не
|
||||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
* ([HttpRequestBuilder.supportsRequestTimeout] проверяет
|
||||||
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
|
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
* - **`POST /conversations/{id}/messages`** — сервер отвечает не сразу, а
|
||||||
|
* только когда ход агента полностью завершён (LLM + тулы). Реальный ход
|
||||||
|
* легко длится минуты, и дефолтный request-timeout убивал бы его на
|
||||||
|
* 15-й секунде, обрывая ещё живой ход на сервере.
|
||||||
|
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis`
|
||||||
|
* (по умолчанию **15000 мс**) молча убивает такой запрос.
|
||||||
*
|
*
|
||||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||||
* установленном capability мутирует его поля через `?:`, так что шаренный
|
* установленном capability мутирует его поля через `?:`, так что шаренный
|
||||||
* инстанс мог бы утечь между запросами.
|
* инстанс мог бы утечь между запросами.
|
||||||
*/
|
*/
|
||||||
internal fun HttpRequestBuilder.noSseReadTimeout() {
|
internal fun HttpRequestBuilder.noReadTimeout() {
|
||||||
setCapability(
|
setCapability(
|
||||||
HttpTimeoutCapability,
|
HttpTimeoutCapability,
|
||||||
HttpTimeoutConfig(
|
HttpTimeoutConfig(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ private fun testEvent(dateMs: Long): CommonEvent =
|
|||||||
CommonEvent.Conversation(
|
CommonEvent.Conversation(
|
||||||
date = Instant.fromEpochMilliseconds(dateMs),
|
date = Instant.fromEpochMilliseconds(dateMs),
|
||||||
conversationId = "test",
|
conversationId = "test",
|
||||||
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
|
event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import kotlin.test.fail
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
|
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
|
||||||
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
|
* = 15 с убивает SSE read. Наш fix — [noReadTimeout] ставит capability
|
||||||
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
|
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
|
||||||
* перед каждым read-стримом.
|
* перед каждым read-стримом.
|
||||||
*
|
*
|
||||||
@@ -43,7 +43,7 @@ class SseTimeoutTest {
|
|||||||
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
|
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
|
fun `sse read survives past default cio timeout with noReadTimeout`(): Unit = runBlocking {
|
||||||
val port = freePort()
|
val port = freePort()
|
||||||
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||||
routing {
|
routing {
|
||||||
@@ -65,7 +65,7 @@ class SseTimeoutTest {
|
|||||||
val received = mutableListOf<String>()
|
val received = mutableListOf<String>()
|
||||||
client.prepareGet("http://127.0.0.1:$port/sse") {
|
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||||
header("Accept", "text/event-stream")
|
header("Accept", "text/event-stream")
|
||||||
noSseReadTimeout()
|
noReadTimeout()
|
||||||
}.execute { resp ->
|
}.execute { resp ->
|
||||||
val ch = resp.bodyAsChannel()
|
val ch = resp.bodyAsChannel()
|
||||||
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
|
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
|
||||||
@@ -95,14 +95,14 @@ class SseTimeoutTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
|
* Контр-тест: убеждаемся что БЕЗ [noReadTimeout] дефолтный
|
||||||
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
|
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
|
||||||
* Сервер держит stream 17 с; если клиент не выставил capability —
|
* Сервер держит stream 17 с; если клиент не выставил capability —
|
||||||
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
|
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
|
||||||
* дожидаясь "done".
|
* дожидаясь "done".
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
|
fun `without noReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
|
||||||
val port = freePort()
|
val port = freePort()
|
||||||
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||||
routing {
|
routing {
|
||||||
@@ -123,7 +123,7 @@ class SseTimeoutTest {
|
|||||||
try {
|
try {
|
||||||
client.prepareGet("http://127.0.0.1:$port/sse") {
|
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||||
header("Accept", "text/event-stream")
|
header("Accept", "text/event-stream")
|
||||||
// НАМЕРЕННО без noSseReadTimeout.
|
// НАМЕРЕННО без noReadTimeout.
|
||||||
}.execute { resp ->
|
}.execute { resp ->
|
||||||
val ch = resp.bodyAsChannel()
|
val ch = resp.bodyAsChannel()
|
||||||
// Читаем строки, пока не придёт "data: done" — без capability
|
// Читаем строки, пока не придёт "data: done" — без capability
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# `:content-api` — общие типы содержимого сообщения
|
||||||
|
|
||||||
|
Низкоуровневый KMP-модуль с типами, которые используются во всех слоях
|
||||||
|
agentik и раньше дублировались:
|
||||||
|
|
||||||
|
- `Content` — часть содержимого сообщения: `Content.Text(body)`,
|
||||||
|
`Content.Image(data, mime)`.
|
||||||
|
- `MessageContext` — контекст инициации хода (`origin`, `description`,
|
||||||
|
`sourceId`, `metadata`).
|
||||||
|
- `MessageOrigin` — `USER` / `SYSTEM` / `EVENT`.
|
||||||
|
- `TurnTokens` — token usage одного assistant turn'а (`input`, `output`).
|
||||||
|
|
||||||
|
## Зачем отдельный модуль
|
||||||
|
|
||||||
|
`:proto` (wire-контракт), `:journal-api` (слой хранения) и `:outbox-api`
|
||||||
|
(события) должны ссылаться на **один и тот же** `Content`/`MessageContext`,
|
||||||
|
а не держать по собственной копии. Общий модуль убирает дубли и циклы:
|
||||||
|
|
||||||
|
```
|
||||||
|
:content-api ◄── :proto
|
||||||
|
◄── :journal-api
|
||||||
|
◄── :outbox-api
|
||||||
|
```
|
||||||
|
|
||||||
|
`:proto`/`:journal-api`/`:outbox-api` объявляют `api(project(":content-api"))`,
|
||||||
|
поэтому потребители (`:client`, `:server`, `:standalone`, ...) видят типы
|
||||||
|
транзитивно, но должны импортировать их напрямую из `pw.binom.agentik.content`.
|
||||||
|
|
||||||
|
## Публикация
|
||||||
|
|
||||||
|
Каталог `gradle/libs.versions.toml` → `agentik-content-api`.
|
||||||
|
`./gradlew :content-api:publish -Pversion=...` публикует все KMP-таргеты
|
||||||
|
(jvm + натив).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
// для JsonElement в MessageContext.metadata
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package pw.binom.agentik.content
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Часть содержимого сообщения (пользовательского или агентского).
|
||||||
|
*
|
||||||
|
* Единый тип для всего проекта: используется и в wire-контракте ([pw.binom.agentik.proto]),
|
||||||
|
* и в слое хранения ([pw.binom.agentik.journal]), и в durable-событиях
|
||||||
|
* ([pw.binom.agentik.outbox.Event]). Вынесен в отдельный модуль `:content-api`,
|
||||||
|
* чтобы не дублировать его в каждом слое и не заводить циклов в графе.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface Content {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("text")
|
||||||
|
data class Text(val body: String) : Content
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Картинка. [mime] — MIME-тип, например `"image/png"`, `"image/jpeg"`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("image")
|
||||||
|
data class Image(val data: ByteArray, val mime: String) : Content {
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
|
||||||
|
|
||||||
|
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package pw.binom.agentik.content
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
|
||||||
|
*
|
||||||
|
* Примеры:
|
||||||
|
* ```
|
||||||
|
* // cron-задача утренней сводки
|
||||||
|
* MessageContext(
|
||||||
|
* origin = MessageOrigin.EVENT,
|
||||||
|
* description = "scheduled cron 'morning-briefing'",
|
||||||
|
* sourceId = "cron-42",
|
||||||
|
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // обычное сообщение из IRC
|
||||||
|
* MessageContext(
|
||||||
|
* origin = MessageOrigin.USER,
|
||||||
|
* sourceId = "irc-channel:agentik",
|
||||||
|
* description = "PRIVMSG from nick",
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Семантический контракт:
|
||||||
|
* - origin != USER ⇒ [description] обязателен и должен быть человекочитаемым.
|
||||||
|
* - origin == USER ⇒ context может быть `null` (дефолт).
|
||||||
|
*
|
||||||
|
* Снапшот-стабильность wire-формата: поля сериализуются по именам, snake_case
|
||||||
|
* на enum'е [MessageOrigin] даёт `user`/`system`/`event`. Новые поля —
|
||||||
|
* non-breaking для старых клиентов.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MessageContext(
|
||||||
|
val origin: MessageOrigin,
|
||||||
|
/**
|
||||||
|
* Короткая человекочитаемая фраза для LLM: попадает в working memory
|
||||||
|
* как префикс `[origin] description (sourceId=…)` к user-сообщению.
|
||||||
|
*/
|
||||||
|
val description: String? = null,
|
||||||
|
/**
|
||||||
|
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC.
|
||||||
|
*/
|
||||||
|
val sourceId: String? = null,
|
||||||
|
/**
|
||||||
|
* Произвольный структурированный payload о событии. Никогда не попадает
|
||||||
|
* в LLM-нагрузку как сырой JSON — только логирование и пост-аналитика.
|
||||||
|
*/
|
||||||
|
val metadata: JsonElement? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pw.binom.agentik.content
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кто/что инициировал ход (кто/что и почему).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class MessageOrigin {
|
||||||
|
@SerialName("user")
|
||||||
|
USER,
|
||||||
|
|
||||||
|
@SerialName("system")
|
||||||
|
SYSTEM,
|
||||||
|
|
||||||
|
@SerialName("event")
|
||||||
|
EVENT,
|
||||||
|
}
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.journal
|
package pw.binom.agentik.content
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ data class TurnTokens(
|
|||||||
val output: Int,
|
val output: Int,
|
||||||
) {
|
) {
|
||||||
val total: Int get() = input + output
|
val total: Int get() = input + output
|
||||||
|
|
||||||
init {
|
init {
|
||||||
require(input >= 0) { "input tokens must be non-negative, got $input" }
|
require(input >= 0) { "input tokens must be non-negative, got $input" }
|
||||||
require(output >= 0) { "output tokens must be non-negative, got $output" }
|
require(output >= 0) { "output tokens must be non-negative, got $output" }
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.proto
|
package pw.binom.agentik.content
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
@@ -2,8 +2,8 @@ package pw.binom.agentik.context
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.journal.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
// ksqlite живёт в libs.versions.toml (см. catalog) — обычный
|
||||||
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
implementation("pw.binom.db:ksqlite:0.1.2")
|
implementation(libs.ksqlite)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
api(project(":context-api"))
|
api(project(":context-api"))
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ package pw.binom.agentik.context.ksqlite
|
|||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.db.ksqlite.SQLiteConnection
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.BeforeTest
|
import kotlin.test.BeforeTest
|
||||||
|
|||||||
+19
-12
@@ -47,28 +47,35 @@ agentik
|
|||||||
## 3. `:proto` — интерфейсы
|
## 3. `:proto` — интерфейсы
|
||||||
|
|
||||||
`Agent` (см. `proto/src/commonMain/.../Agent.kt`):
|
`Agent` (см. `proto/src/commonMain/.../Agent.kt`):
|
||||||
- `id: String`
|
- `id: String`, `info: AgentInfo`
|
||||||
|
- read-only сторы: `journal: JournalStore`, `outbox: OutboxStore`,
|
||||||
|
`onlineOutbox: OnlineOutbox`, `conversationStore: ConversationStore`
|
||||||
- `createConversation(temp: Boolean): Conversation`
|
- `createConversation(temp: Boolean): Conversation`
|
||||||
- `suspend getConversation(id): Conversation?`
|
- `suspend getConversation(id): Conversation?`
|
||||||
- `suspend getConversations(offset, limit): List<Conversation>`
|
- `suspend deleteConversation(id): Boolean`
|
||||||
- `getConversations(offset = 0): Flow<Conversation>` — cold-flow paging через suspend-версию, `PAGE_SIZE = 100`.
|
- `suspend renameConversation(id, title): Instant?`
|
||||||
- `events(after: Instant): Flow<AgentEvent>` — replay-free, бэкфилл через snapshot.
|
|
||||||
- `deleteConversation(id): Boolean`
|
|
||||||
|
|
||||||
`Conversation`:
|
`Conversation`:
|
||||||
- `isSupportImageInput / Output / isTemporal: Boolean`
|
- `isSupportImageInput / Output / isTemporal: Boolean`, `title: String?`
|
||||||
- `updatedAt: Instant`
|
- `updatedAt: Instant`
|
||||||
- `send(content: List<Content>)` — write-only, ничего не возвращает.
|
- `send(content: List<Content>, context: MessageContext? = null)` — write-only, ничего не возвращает.
|
||||||
- `interrupt()` — отмена активного хода.
|
- `interrupt()` — отмена активного хода.
|
||||||
- `events(after): Flow<Event>` — live, replay-free.
|
- `getMessages(after, offset, limit): List<Message>` — paging.
|
||||||
- `getMessages(after, offset, limit)` + `getMessages(after): Flow<Message>` — paging.
|
|
||||||
- `rename(title)` — мутация, бампит `updatedAt`.
|
- `rename(title)` — мутация, бампит `updatedAt`.
|
||||||
- `AutoCloseable` — `close()` идемпотентен.
|
- `AutoCloseable` — `close()` идемпотентен.
|
||||||
|
|
||||||
`Content = Text(body) | Image(data, mime)`.
|
`Content = Text(body) | Image(data, mime)` — из `:content-api` (там же `MessageContext`/`MessageOrigin`/`TurnTokens`).
|
||||||
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
|
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
|
||||||
`Event = StartReasoning | StartResponse | End | AppendText | AppendImage | ToolCall | ToolResult | Error`.
|
|
||||||
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title)`.
|
События разделены на два потока (оба в `:outbox-api`):
|
||||||
|
- **durable** `Event` (`outbox.conversationEvents(after, id)`): `UserMessage |
|
||||||
|
AssistantMessage | ToolCall | ToolResult | ToolFailed | Interrupted | Error |
|
||||||
|
ConversationClosing | CompactionTriggered` — перезапрашиваются по курсору;
|
||||||
|
- **online** `OnlineEvent` (`onlineOutbox.onlineEvents(id)`): `Working | End |
|
||||||
|
StartReasoning | StartResponse | AppendText | AppendImage` — live-only,
|
||||||
|
никогда не сохраняются.
|
||||||
|
|
||||||
|
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title) | Touched(...)`.
|
||||||
|
|
||||||
Принцип: **агент — источник истины** для транскрипта и сессий. Клиент
|
Принцип: **агент — источник истины** для транскрипта и сессий. Клиент
|
||||||
лишь рендерит Event-stream и кэширует историю.
|
лишь рендерит Event-stream и кэширует историю.
|
||||||
|
|||||||
+6
-5
@@ -63,9 +63,9 @@
|
|||||||
│ 3. ensureLiteConversation: │
|
│ 3. ensureLiteConversation: │
|
||||||
│ first turn → create from WM; │
|
│ first turn → create from WM; │
|
||||||
│ next turns → reuse (KV-cache) │
|
│ next turns → reuse (KV-cache) │
|
||||||
│ 4. sendStreamContents → emit │
|
│ 4. sendStreamContents → online: │
|
||||||
│ StartResponse / AppendText / │
|
│ StartResponse/AppendText/End; │
|
||||||
│ End │
|
│ durable: AssistantMessage │
|
||||||
│ 5. audit + WM: append AssistantMessage│
|
│ 5. audit + WM: append AssistantMessage│
|
||||||
└──────────────────────────────────────┘
|
└──────────────────────────────────────┘
|
||||||
│ │
|
│ │
|
||||||
@@ -151,7 +151,8 @@ fun main() {
|
|||||||
| `updatedAt: Instant` | последний `send`/`rename` |
|
| `updatedAt: Instant` | последний `send`/`rename` |
|
||||||
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
|
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
|
||||||
| `interrupt()` | остановить текущий ход (best-effort) |
|
| `interrupt()` | остановить текущий ход (best-effort) |
|
||||||
| `events(after): Flow<Event>` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) |
|
| `outbox.conversationEvents(after, id): Flow<Event>` | durable-события (UserMessage, AssistantMessage, ToolCall/Result, Interrupted, Error) — скурсором |
|
||||||
|
| `onlineOutbox.onlineEvents(id): Flow<OnlineEvent>` | live-only стриминг (Working, End, StartReasoning, StartResponse, AppendText, AppendImage) |
|
||||||
| `getMessages(after, offset, limit)` | страница истории |
|
| `getMessages(after, offset, limit)` | страница истории |
|
||||||
| `rename(title)` | переименовать |
|
| `rename(title)` | переименовать |
|
||||||
| `close()` | освободить ресурсы |
|
| `close()` | освободить ресурсы |
|
||||||
@@ -173,7 +174,7 @@ fun main() {
|
|||||||
|
|
||||||
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
|
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
|
||||||
|
|
||||||
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит durable `Event.Error` + онлайн `OnlineEvent.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
||||||
|
|
||||||
### `JournalStore`
|
### `JournalStore`
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ logback = "1.5.18"
|
|||||||
mosaic = "0.18.0"
|
mosaic = "0.18.0"
|
||||||
clikt = "5.0.3"
|
clikt = "5.0.3"
|
||||||
kotlinx-cli = "0.3.6"
|
kotlinx-cli = "0.3.6"
|
||||||
|
ksqlite = "0.1.4"
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
@@ -96,6 +98,14 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
|
|||||||
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
|
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
|
||||||
# kmmio-core = { module = "dev.karmakrafts.kmmio:kmmio-core", version = "2.3.1" }
|
# kmmio-core = { module = "dev.karmakrafts.kmmio:kmmio-core", version = "2.3.1" }
|
||||||
|
|
||||||
|
# --- ksqlite (pw.binom.db) — KMP SQLite со встроенным sqlite-vec (https://github.com/caffeine-mgn/ksqlite).
|
||||||
|
# Тянется как обычная `implementation(libs.ksqlite)` — Gradle Module Metadata резолвит per-target
|
||||||
|
# variant (ksqlite-jvm / ksqlite-linuxx64 / ksqlite-mingwx64 / ...).
|
||||||
|
ksqlite = { module = "pw.binom.db:ksqlite", version.ref = "ksqlite" }
|
||||||
|
|
||||||
|
# --- JUnit 4 — legacy test framework (используется в :client для совместимости с ktor-server-test-host). ---
|
||||||
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
|
||||||
# --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. ---
|
# --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. ---
|
||||||
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
api(project(":content-api"))
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
api(libs.kotlinx.serialization.json)
|
api(libs.kotlinx.serialization.json)
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package pw.binom.agentik.journal
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
|
|
||||||
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
|
|
||||||
* в `Mapping.kt` storage impl'ов.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
sealed interface Content {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("text")
|
|
||||||
data class Text(val body: String) : Content
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("image")
|
|
||||||
data class Image(val data: ByteArray, val mime: String) : Content {
|
|
||||||
override fun equals(other: Any?): Boolean =
|
|
||||||
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
|
|
||||||
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package pw.binom.agentik.journal
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot диалога. В таблице `conversation` хранится как есть.
|
* Snapshot диалога. В таблице `conversation` хранится как есть.
|
||||||
*/
|
*/
|
||||||
|
@Serializable
|
||||||
data class ConversationRecord(
|
data class ConversationRecord(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String?,
|
val title: String?,
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package pw.binom.agentik.journal
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MessageContext(
|
|
||||||
val origin: MessageOrigin,
|
|
||||||
val sourceId: String? = null,
|
|
||||||
val description: String? = null,
|
|
||||||
val metadata: JsonElement? = null,
|
|
||||||
)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package pw.binom.agentik.journal
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Контекст инициации хода (кто/что и почему). Дубликат типа из `:proto` —
|
|
||||||
* живёт здесь чтобы не тащить `:proto` в слой хранения данных.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
enum class MessageOrigin {
|
|
||||||
@SerialName("user")
|
|
||||||
USER,
|
|
||||||
|
|
||||||
@SerialName("system")
|
|
||||||
SYSTEM,
|
|
||||||
|
|
||||||
@SerialName("event")
|
|
||||||
EVENT,
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,9 @@ package pw.binom.agentik.journal
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
|
import pw.binom.agentik.content.MessageContext
|
||||||
|
import pw.binom.agentik.content.TurnTokens
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +39,12 @@ sealed interface MessageRecord {
|
|||||||
override val content: List<Content>,
|
override val content: List<Content>,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
val tokens: TurnTokens? = null,
|
val tokens: TurnTokens? = null,
|
||||||
|
/**
|
||||||
|
* Текст размышлений модели (chain-of-thought / reasoning), если провайдер
|
||||||
|
* его отдаёт. Опционально: null, если размышлений не было или провайдер
|
||||||
|
* их не раскрывает.
|
||||||
|
*/
|
||||||
|
val reasoning: String? = null,
|
||||||
) : Body
|
) : Body
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
|
import pw.binom.agentik.content.MessageContext
|
||||||
|
import pw.binom.agentik.content.TurnTokens
|
||||||
|
|
||||||
private val bodyJson = Json {
|
private val bodyJson = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@@ -17,15 +20,17 @@ data class MessageBodyPayload(
|
|||||||
@SerialName("context")
|
@SerialName("context")
|
||||||
val context: MessageContext? = null,
|
val context: MessageContext? = null,
|
||||||
val tokens: TurnTokens? = null,
|
val tokens: TurnTokens? = null,
|
||||||
|
val reasoning: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun encodeBodyPayload(
|
fun encodeBodyPayload(
|
||||||
content: List<Content>,
|
content: List<Content>,
|
||||||
context: MessageContext? = null,
|
context: MessageContext? = null,
|
||||||
tokens: TurnTokens? = null,
|
tokens: TurnTokens? = null,
|
||||||
|
reasoning: String? = null,
|
||||||
): String = bodyJson.encodeToString(
|
): String = bodyJson.encodeToString(
|
||||||
MessageBodyPayload.serializer(),
|
MessageBodyPayload.serializer(),
|
||||||
MessageBodyPayload(content = content, context = context, tokens = tokens),
|
MessageBodyPayload(content = content, context = context, tokens = tokens, reasoning = reasoning),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
|
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
|
||||||
@@ -34,14 +39,15 @@ data class BodyDecoded(
|
|||||||
val content: List<Content>,
|
val content: List<Content>,
|
||||||
val context: MessageContext?,
|
val context: MessageContext?,
|
||||||
val tokens: TurnTokens? = null,
|
val tokens: TurnTokens? = null,
|
||||||
|
val reasoning: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun readPayload(json: String): BodyDecoded {
|
private fun readPayload(json: String): BodyDecoded {
|
||||||
return try {
|
return try {
|
||||||
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
|
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
|
||||||
BodyDecoded(p.content, p.context, p.tokens)
|
BodyDecoded(p.content, p.context, p.tokens, p.reasoning)
|
||||||
} catch (e: kotlinx.serialization.SerializationException) {
|
} catch (e: kotlinx.serialization.SerializationException) {
|
||||||
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
|
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
|
||||||
BodyDecoded(arr, null, null)
|
BodyDecoded(arr, null, null, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ class InMemoryJournalStoreTest {
|
|||||||
MessageRecord.UserMessage(
|
MessageRecord.UserMessage(
|
||||||
id = id,
|
id = id,
|
||||||
conversationId = convId,
|
conversationId = convId,
|
||||||
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
|
content = listOf(pw.binom.agentik.content.Content.Text(text)),
|
||||||
createdAt = at,
|
createdAt = at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
// ksqlite живёт в libs.versions.toml (см. catalog) — обычный
|
||||||
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
implementation("pw.binom.db:ksqlite:0.1.2")
|
implementation(libs.ksqlite)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
|
|||||||
+2
-1
@@ -21,6 +21,7 @@ internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (r
|
|||||||
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
|
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
|
||||||
content = record.content,
|
content = record.content,
|
||||||
tokens = record.tokens,
|
tokens = record.tokens,
|
||||||
|
reasoning = record.reasoning,
|
||||||
)
|
)
|
||||||
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
||||||
CallPayload.serializer(),
|
CallPayload.serializer(),
|
||||||
@@ -49,7 +50,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
|||||||
}
|
}
|
||||||
"assistant" -> {
|
"assistant" -> {
|
||||||
val d = decodeBodyPayload(payload)
|
val d = decodeBodyPayload(payload)
|
||||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
|
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning)
|
||||||
}
|
}
|
||||||
"tool_call" -> {
|
"tool_call" -> {
|
||||||
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||||
|
|||||||
+2
-2
@@ -2,9 +2,9 @@ package pw.binom.agentik.journal.ksqlite
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.journal.TurnTokens
|
import pw.binom.agentik.content.TurnTokens
|
||||||
import pw.binom.db.ksqlite.SQLiteConnection
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.BeforeTest
|
import kotlin.test.BeforeTest
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
package pw.binom.agentik.journal.ksqlite
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.db.ksqlite.SQLiteConnection
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
// ksqlite живёт в libs.versions.toml (см. catalog) — обычный
|
||||||
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
implementation("pw.binom.db:ksqlite:0.1.2")
|
implementation(libs.ksqlite)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.io.core)
|
implementation(libs.kotlinx.io.core)
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены
|
// :proto не нужен (Event/OnlineEvent/AgentEvent/CommonEvent живут
|
||||||
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization).
|
// здесь). Message-события несут общие типы содержимого из
|
||||||
|
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
|
||||||
|
api(project(":content-api"))
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
api(libs.kotlinx.serialization.json)
|
api(libs.kotlinx.serialization.json)
|
||||||
|
|||||||
@@ -2,18 +2,25 @@ package pw.binom.agentik.outbox
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
|
import pw.binom.agentik.content.MessageContext
|
||||||
|
import pw.binom.agentik.content.TurnTokens
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Элемент live-потока `Conversation.events(after)`.
|
* Элемент **durable**-потока диалога.
|
||||||
*
|
*
|
||||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||||
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
* как курсор («где остановился») при обрыве/переподключении и для разрешения
|
||||||
* порядка при равных timestamps.
|
* порядка при равных timestamps.
|
||||||
*
|
*
|
||||||
* Базовая структура хода:
|
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
|
||||||
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
|
||||||
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
|
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
|
||||||
|
* Их можно перезапросить по курсору (`after`).
|
||||||
|
*
|
||||||
|
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
|
||||||
|
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
|
||||||
*
|
*
|
||||||
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
|
||||||
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
|
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
|
||||||
@@ -27,39 +34,36 @@ sealed interface Event {
|
|||||||
/** Момент эмиссии события в UTC. */
|
/** Момент эмиссии события в UTC. */
|
||||||
val date: Instant
|
val date: Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Целое пользовательское сообщение хода. Эмитится в тот же момент, когда
|
||||||
|
* запись попадает в журнал (для персистентных диалогов). [id] совпадает
|
||||||
|
* с id соответствующего `MessageRecord.UserMessage`/`Message.UserMessage`.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class ResponseType {
|
@SerialName("user_message")
|
||||||
@SerialName("text") TEXT,
|
data class UserMessage(
|
||||||
@SerialName("image") IMAGE
|
override val date: Instant,
|
||||||
}
|
val id: String,
|
||||||
|
val content: List<Content>,
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
) : Event
|
||||||
|
|
||||||
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
|
/**
|
||||||
|
* Целое сообщение ассистента — итог хода. Эмитится при завершении хода,
|
||||||
|
* после того как текст ответа полностью собран.
|
||||||
|
*
|
||||||
|
* [reasoning] — текст размышлений модели (chain-of-thought), если провайдер
|
||||||
|
* его отдаёт; иначе `null`. [tokens] — расход токенов за ход.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("start_reasoning")
|
@SerialName("assistant_message")
|
||||||
data class StartReasoning(override val date: Instant) : Event
|
data class AssistantMessage(
|
||||||
|
override val date: Instant,
|
||||||
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
|
val id: String,
|
||||||
@Serializable
|
val content: List<Content>,
|
||||||
@SerialName("start_response")
|
val reasoning: String? = null,
|
||||||
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
val tokens: TurnTokens? = null,
|
||||||
|
) : Event
|
||||||
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("end")
|
|
||||||
data class End(override val date: Instant) : Event
|
|
||||||
|
|
||||||
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("interrupted")
|
|
||||||
data class Interrupted(override val date: Instant) : Event
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("append_text")
|
|
||||||
data class AppendText(override val date: Instant, val body: String) : Event
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("append_image")
|
|
||||||
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||||
@@ -99,6 +103,14 @@ sealed interface Event {
|
|||||||
val result: String?,
|
val result: String?,
|
||||||
) : Event
|
) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется
|
||||||
|
* в истории.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("interrupted")
|
||||||
|
data class Interrupted(override val date: Instant) : Event
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
||||||
* прийти, но ход считается проваленным.
|
* прийти, но ход считается проваленным.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write-сторона [OnlineOutbox]: используется продюсерами
|
||||||
|
* ([pw.binom.agentik.standalone.agent.ConversationLoop] и т.п.) для эмиссии
|
||||||
|
* live-дельт. Наружу (в [pw.binom.agentik.proto.Agent]) отдаётся
|
||||||
|
* read-only [OnlineOutbox].
|
||||||
|
*
|
||||||
|
* **Non-suspend и best-effort**: онлайн-события по определению нигде не
|
||||||
|
* персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline]
|
||||||
|
* не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может
|
||||||
|
* потерять дельту, это допустимо.
|
||||||
|
*/
|
||||||
|
interface MutableOnlineOutbox : OnlineOutbox {
|
||||||
|
|
||||||
|
suspend fun appendOnline(conversationId: String, event: OnlineEvent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эмитит [event] в live-канал диалога [conversationId]. Не сохраняется.
|
||||||
|
*
|
||||||
|
* Возвращает `true`, если событие принято live-каналом. Возврат `false`
|
||||||
|
* (нет активных подписчиков / буфер переполнен с DROP-политикой) —
|
||||||
|
* не ошибка: у онлайн-событий нет гарантии доставки.
|
||||||
|
*/
|
||||||
|
fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
|
||||||
|
* стриминг ответа агента (дельты текста/картинок).
|
||||||
|
*
|
||||||
|
* Принципиальное отличие от [Event] (durable):
|
||||||
|
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
|
||||||
|
* ни в journal. Это чистый live-канал.
|
||||||
|
* - **Только онлайн-подписка**: события, эмитнутые до подписки
|
||||||
|
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
|
||||||
|
* Потерянный фрагмент не страшен — целый результат хода приходит
|
||||||
|
* durable-событием ([Event.AssistantMessage]) и/или лежит в журнале.
|
||||||
|
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
|
||||||
|
* есть что реплеить.
|
||||||
|
*
|
||||||
|
* Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
|
||||||
|
* который, попав в durable store, копится в RAM (standalone-outbox растёт
|
||||||
|
* unbounded) и засоряет историю. В [Event] остаются только «целые» события,
|
||||||
|
* пригодные к перезапросу по курсору.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface OnlineEvent {
|
||||||
|
/** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ResponseType {
|
||||||
|
@SerialName("text") TEXT,
|
||||||
|
@SerialName("image") IMAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
|
||||||
|
* [End]/[Event.Interrupted]/[Event.Error], синхронно из `Conversation.send()`,
|
||||||
|
* чтобы UI мог показать спиннер ещё до первого токена ответа.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("working")
|
||||||
|
data class Working(override val date: Instant) : OnlineEvent
|
||||||
|
|
||||||
|
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [Event]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("end")
|
||||||
|
data class End(override val date: Instant) : OnlineEvent
|
||||||
|
|
||||||
|
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("start_reasoning")
|
||||||
|
data class StartReasoning(override val date: Instant) : OnlineEvent
|
||||||
|
|
||||||
|
/** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("start_response")
|
||||||
|
data class StartResponse(override val date: Instant, val responseType: ResponseType) : OnlineEvent
|
||||||
|
|
||||||
|
/** Очередная дельта текста ответа. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append_text")
|
||||||
|
data class AppendText(override val date: Instant, val body: String) : OnlineEvent
|
||||||
|
|
||||||
|
/** Очередная дельта картинки ответа. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append_image")
|
||||||
|
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : OnlineEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
|
||||||
|
* агента «в моменте».
|
||||||
|
*
|
||||||
|
* Контракт принципиально проще [OutboxStore]:
|
||||||
|
* - **без catchup**: `onlineEvents` отдаёт только то, что эмитится *после*
|
||||||
|
* подписки. Прошлое не реплеится — его и нет (нечего хранить).
|
||||||
|
* - **без курсора**: нет `after`/`lastSeen`.
|
||||||
|
* - **без TTL/buffer**: [OnlineEvent] не буферизуются.
|
||||||
|
*
|
||||||
|
* Это осознанный компромисс: дельты токенов — высокочастотный мусор,
|
||||||
|
* который в durable-сторе копился бы в RAM и засорял историю. Потеря
|
||||||
|
* фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage]
|
||||||
|
* и/или лежит в [pw.binom.agentik.journal.JournalStore].
|
||||||
|
*
|
||||||
|
* Read-only view: запись — через [MutableOnlineOutbox].
|
||||||
|
*/
|
||||||
|
interface OnlineOutbox : AutoCloseable {
|
||||||
|
|
||||||
|
fun onlineEvents(): Flow<OnlineEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписка на live-поток онлайн-событий диалога [conversationId].
|
||||||
|
* События, эмитнутые до подписки, не приходят.
|
||||||
|
*/
|
||||||
|
fun onlineEvents(conversationId: String): Flow<OnlineEvent>
|
||||||
|
|
||||||
|
/** Освобождает ресурсы. Idempotent. */
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ import kotlin.time.Instant
|
|||||||
/**
|
/**
|
||||||
* Bounded-tail event log с автоматическим управлением TTL.
|
* Bounded-tail event log с автоматическим управлением TTL.
|
||||||
*
|
*
|
||||||
|
* Хранит **только durable-события [Event]** — «целые» факты хода
|
||||||
|
* (Working/End/Interrupted/Error, ToolCall/ToolResult/ToolFailed).
|
||||||
|
* Высокочастотный **стриминг ответа** (дельты текста/картинок) сюда
|
||||||
|
* НЕ попадает — он живёт в [OnlineOutbox] (live-only, не сохраняется).
|
||||||
|
*
|
||||||
* **Архитектура двухуровневого хранилища событий**:
|
* **Архитектура двухуровневого хранилища событий**:
|
||||||
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
|
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
|
||||||
* События автоматически эвиктятся по TTL/cap (implementation-defined).
|
* События автоматически эвиктятся по TTL/cap (implementation-defined).
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package pw.binom.agentik.outbox.inmemory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory реализация [MutableOnlineOutbox] — единственный live-канал
|
||||||
|
* без хранения.
|
||||||
|
*
|
||||||
|
* **Никакого буфера событий**: [OnlineEvent] нигде не накапливаются
|
||||||
|
* (в этом весь смысл — дельты токенов не должны течь в durable-стор).
|
||||||
|
* Единственное, что живёт в памяти, — [MutableSharedFlow] с BOUNDED
|
||||||
|
* internal buffer'ом для развязки продюсера/подписчиков; при переполнении
|
||||||
|
* **старые дропаются** ([BufferOverflow.DROP_OLDEST]), [appendOnline] не
|
||||||
|
* блокируется. Потеря дельты допустима (см. [OnlineOutbox]).
|
||||||
|
*
|
||||||
|
* **Маршрутизация**: один общий [MutableSharedFlow] c `conversationId`
|
||||||
|
* в envelope; [onlineEvents] фильтрует по диалогу. Отдельный flow-на-диалог
|
||||||
|
* не держим, чтобы не плодить per-conversation подписки, которые надо
|
||||||
|
* чистить вручную.
|
||||||
|
*/
|
||||||
|
class InMemoryOnlineOutbox(
|
||||||
|
liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY,
|
||||||
|
) : MutableOnlineOutbox {
|
||||||
|
|
||||||
|
private data class Envelope(val conversationId: String, val event: OnlineEvent)
|
||||||
|
|
||||||
|
private val liveFlow = MutableSharedFlow<Envelope>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = liveBufferCapacity,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(liveBufferCapacity > 0) {
|
||||||
|
"liveBufferCapacity must be > 0, got $liveBufferCapacity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onlineEvents(): Flow<OnlineEvent> =
|
||||||
|
liveFlow.map { it.event }
|
||||||
|
|
||||||
|
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
|
||||||
|
liveFlow.filter { it.conversationId == conversationId }.map { it.event }
|
||||||
|
|
||||||
|
override suspend fun appendOnline(conversationId: String, event: OnlineEvent)=
|
||||||
|
liveFlow.emit(Envelope(conversationId, event))
|
||||||
|
|
||||||
|
override fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean =
|
||||||
|
liveFlow.tryEmit(Envelope(conversationId, event))
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки.
|
||||||
|
// Идемпотентно: повторный close() безопасен.
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Достаточно, чтобы не терять подряд идущие маркеры фаз
|
||||||
|
// (Working/StartReasoning/StartResponse) и первые дельты, пока
|
||||||
|
// подписчик не возобновил сбор. Дельты токенов при переполнении
|
||||||
|
// всё равно дропаются (DROP_OLDEST) — потеря фрагмента допустима.
|
||||||
|
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 64
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -164,7 +164,7 @@ class InMemoryOutboxStoreTest {
|
|||||||
store.append(CommonEvent.Conversation(
|
store.append(CommonEvent.Conversation(
|
||||||
date = now,
|
date = now,
|
||||||
conversationId = "c-1",
|
conversationId = "c-1",
|
||||||
event = Event.AppendText(date = now, body = "hi"),
|
event = Event.Interrupted(date = now),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Snapshot-based test of the default impl (uses events() + filterIsInstance).
|
// Snapshot-based test of the default impl (uses events() + filterIsInstance).
|
||||||
@@ -180,9 +180,9 @@ class InMemoryOutboxStoreTest {
|
|||||||
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
|
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
|
||||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
val now = Instant.fromEpochSeconds(0)
|
val now = Instant.fromEpochSeconds(0)
|
||||||
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "a")))
|
store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
|
||||||
store.append(CommonEvent.Conversation(now, "c-2", Event.AppendText(now, "b")))
|
store.append(CommonEvent.Conversation(now, "c-2", Event.Interrupted(now)))
|
||||||
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "c")))
|
store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
|
||||||
|
|
||||||
// Test the filter logic by manually filtering snapshot.
|
// Test the filter logic by manually filtering snapshot.
|
||||||
val c1 = store.snapshot()
|
val c1 = store.snapshot()
|
||||||
@@ -200,7 +200,7 @@ class InMemoryOutboxStoreTest {
|
|||||||
date = now,
|
date = now,
|
||||||
event = AgentEvent.Created(date = now, conversationId = "created"),
|
event = AgentEvent.Created(date = now, conversationId = "created"),
|
||||||
))
|
))
|
||||||
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "hi")))
|
store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
|
||||||
|
|
||||||
val all = store.snapshot()
|
val all = store.snapshot()
|
||||||
val agents = all.filterIsInstance<CommonEvent.Agent>()
|
val agents = all.filterIsInstance<CommonEvent.Agent>()
|
||||||
|
|||||||
+56
-26
@@ -59,57 +59,87 @@ target-specific артефакты + общий `kotlinMultiplatform`.
|
|||||||
|
|
||||||
## Основные типы
|
## Основные типы
|
||||||
|
|
||||||
|
`:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`,
|
||||||
|
а общие типы содержимого и события живут в нижележащих модулях:
|
||||||
|
`Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`,
|
||||||
|
`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
interface Agent {
|
interface Agent : AutoCloseable {
|
||||||
fun id: String
|
val id: String
|
||||||
suspend fun createConversation(title: String? = null): Conversation
|
val info: AgentInfo
|
||||||
|
val journal: JournalStore // append-only audit (read-only)
|
||||||
|
val outbox: OutboxStore // durable-события: catchup+live по курсору
|
||||||
|
val onlineOutbox: OnlineOutbox // live-only: стриминг, без курсора
|
||||||
|
val conversationStore: ConversationStore
|
||||||
|
fun createConversation(temp: Boolean): Conversation
|
||||||
suspend fun getConversation(id: String): Conversation?
|
suspend fun getConversation(id: String): Conversation?
|
||||||
suspend fun getConversations(offset: Int = 0): Flow<Conversation>
|
suspend fun deleteConversation(id: String): Boolean
|
||||||
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
|
suspend fun renameConversation(id: String, title: String?): Instant?
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Conversation : AutoCloseable {
|
interface Conversation : AutoCloseable {
|
||||||
val id: String
|
val id: String
|
||||||
val updatedAt: Instant
|
val updatedAt: Instant
|
||||||
|
val isTemporal: Boolean
|
||||||
|
val title: String?
|
||||||
val isSupportImageInput: Boolean
|
val isSupportImageInput: Boolean
|
||||||
val isSupportImageOutput: Boolean
|
val isSupportImageOutput: Boolean
|
||||||
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
|
suspend fun send(content: List<Content>, context: MessageContext? = null) // fire-and-forget
|
||||||
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
|
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
|
||||||
suspend fun getMessages(offset: Int = 0): Flow<Message>
|
suspend fun rename(title: String)
|
||||||
suspend fun rename(title: String): Boolean
|
suspend fun interrupt()
|
||||||
fun interrupt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// :content-api
|
||||||
sealed interface Content {
|
sealed interface Content {
|
||||||
class Text(val body: String) : Content
|
data class Text(val body: String) : Content
|
||||||
class Image(val data: ByteArray, val mime: String) : Content
|
data class Image(val data: ByteArray, val mime: String) : Content
|
||||||
}
|
}
|
||||||
|
enum class MessageOrigin { USER, SYSTEM, EVENT }
|
||||||
|
data class MessageContext(origin: MessageOrigin, description: String?, sourceId: String?, metadata: JsonElement?)
|
||||||
|
|
||||||
|
// :proto
|
||||||
sealed interface Message {
|
sealed interface Message {
|
||||||
val id: String
|
val id: String
|
||||||
val date: Instant
|
val date: Instant
|
||||||
interface Body : Message { val content: List<Content> }
|
class UserMessage(id, content: List<Content>, date, context: MessageContext?) : Message
|
||||||
interface System : Message
|
class AssistantMessage(id, content: List<Content>, date, tokens: TurnTokens?, reasoning: String?) : Message
|
||||||
class UserMessage(...) : Body
|
class ToolCall(...) : Message
|
||||||
class AssistantMessage(...) : Body
|
class ToolResult(...) : Message
|
||||||
class ToolCall(...) : System
|
class Error(...) : Message
|
||||||
class ToolResult(...) : System
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// :outbox-api — durable (перезапрашиваются по курсору `after`)
|
||||||
sealed interface Event {
|
sealed interface Event {
|
||||||
enum ResponseType { TEXT, IMAGE }
|
val date: Instant
|
||||||
class StartReasoning(...) : Event
|
class UserMessage(date, id, content: List<Content>, context: MessageContext?) : Event
|
||||||
class StartResponse(val type: ResponseType) : Event
|
class AssistantMessage(date, id, content: List<Content>, reasoning: String?, tokens: TurnTokens?) : Event
|
||||||
class AppendText(val body: String) : Event
|
|
||||||
class AppendImage(val body: ByteArray, val mime: String) : Event
|
|
||||||
class End(...) : Event
|
|
||||||
class Interrupted(...) : Event
|
|
||||||
class Error(val message: String, val code: Int? = null) : Event
|
|
||||||
class ToolCall(...) : Event
|
class ToolCall(...) : Event
|
||||||
class ToolResult(...) : Event
|
class ToolResult(...) : Event
|
||||||
|
class ToolFailed(...) : Event
|
||||||
|
class Interrupted(date) : Event
|
||||||
|
class Error(date, message, code) : Event
|
||||||
|
class ConversationClosing(...) : Event
|
||||||
|
class CompactionTriggered(...) : Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// :outbox-api — online (live-only, НИКОГДА не сохраняются)
|
||||||
|
sealed interface OnlineEvent {
|
||||||
|
val date: Instant
|
||||||
|
enum ResponseType { TEXT, IMAGE }
|
||||||
|
class Working(date) : OnlineEvent
|
||||||
|
class End(date) : OnlineEvent
|
||||||
|
class StartReasoning(date) : OnlineEvent
|
||||||
|
class StartResponse(date, responseType: ResponseType) : OnlineEvent
|
||||||
|
class AppendText(date, body: String) : OnlineEvent
|
||||||
|
class AppendImage(date, body: ByteArray, mime: String) : OnlineEvent
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ход в терминах маркеров: онлайн `Working` → … → онлайн `End`; durable —
|
||||||
|
`UserMessage` в начале и `AssistantMessage`/`Interrupted`/`Error` в конце.
|
||||||
|
|
||||||
## Чего здесь НЕТ
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ kotlin {
|
|||||||
// public-сигнатуре Agent, поэтому api-висимости.
|
// public-сигнатуре Agent, поэтому api-висимости.
|
||||||
//
|
//
|
||||||
// Линейный граф зависимостей (без циклов):
|
// Линейный граф зависимостей (без циклов):
|
||||||
// :proto ──► :outbox-api (нет обратной зависимости)
|
// :proto ──► :content-api (Content/MessageContext/MessageOrigin/TurnTokens)
|
||||||
// :proto ──► :journal-api (нет обратной зависимости)
|
// :proto ──► :outbox-api (нет обратной зависимости)
|
||||||
// Добились переносом AgentEvent/CommonEvent/Event из :proto в
|
// :proto ──► :journal-api (нет обратной зависимости)
|
||||||
// :outbox-api — они теперь self-contained в outbox (не нужны
|
// Общие типы содержимого живут в низкоуровневом :content-api,
|
||||||
// :proto-типы), а :proto использует их через :outbox-api.
|
// чтобы ими пользовались proto/journal/outbox без дублей и циклов.
|
||||||
|
api(project(":content-api"))
|
||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
api(project(":outbox-api"))
|
api(project(":outbox-api"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package pw.binom.agentik.proto
|
|||||||
|
|
||||||
import pw.binom.agentik.journal.ConversationStore
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -12,9 +14,10 @@ import kotlin.time.Instant
|
|||||||
* [createConversation] возвращает [Conversation], который сам хранит историю
|
* [createConversation] возвращает [Conversation], который сам хранит историю
|
||||||
* и которому отправляют ходы через [Conversation.send].
|
* и которому отправляют ходы через [Conversation.send].
|
||||||
*
|
*
|
||||||
* **Хранилища вынесены в [Agent.journal], [Agent.outbox] и
|
* **Хранилища вынесены в [Agent.journal], [Agent.outbox],
|
||||||
* [Agent.conversationStore]**: все три read-only views. События живут
|
* [Agent.onlineOutbox] и [Agent.conversationStore]**: read-only views.
|
||||||
* в [outbox] как `OutboxStore.events(after)` / `outbox.agentEvents(after)`.
|
* Durable-события живут в [outbox] как `OutboxStore.events(after)` /
|
||||||
|
* `outbox.agentEvents(after)`; live-стриминг ответа — в [onlineOutbox].
|
||||||
* Это даёт единый путь для всех read-операций по хранилищу и убирает
|
* Это даёт единый путь для всех read-операций по хранилищу и убирает
|
||||||
* дублирование между протоколом и хранилищем.
|
* дублирование между протоколом и хранилищем.
|
||||||
*
|
*
|
||||||
@@ -28,6 +31,26 @@ interface Agent : AutoCloseable {
|
|||||||
/** Идентификатор агента. */
|
/** Идентификатор агента. */
|
||||||
val id: String
|
val id: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичное представление имени/назначения агента — то, что агент
|
||||||
|
* сам "рассказывает о себе" клиентам и UI.
|
||||||
|
*
|
||||||
|
* Контрактно отличается от [id]:
|
||||||
|
* - [id] — opaque stable identifier (логи, корреляция, A2A contextId);
|
||||||
|
* - [info.name] — **человекочитаемое** имя, которое пользователь
|
||||||
|
* может сменить через конфиг или UI; может меняться в течение жизни
|
||||||
|
* агента.
|
||||||
|
*
|
||||||
|
* Используется HTTP-фасадом `:server` (endpoint под `path` самого
|
||||||
|
* агента) и A2A-фасадом (для заполнения AgentCard `name` / `description`).
|
||||||
|
*
|
||||||
|
* Доступ к [info] всегда дешёвый — это snapshot `data class` без побочных
|
||||||
|
* эффектов. Конкретные реализации ([MutableAgent]) могут выставлять
|
||||||
|
* `info` конструкторным параметром; [Agent] контрактно не требует
|
||||||
|
* реактивности — клиенты получают снимок в момент обращения.
|
||||||
|
*/
|
||||||
|
val info: AgentInfo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки).
|
* Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки).
|
||||||
* После [close] вызовы [createConversation] / [getConversation] и т.п.
|
* После [close] вызовы [createConversation] / [getConversation] и т.п.
|
||||||
@@ -63,6 +86,24 @@ interface Agent : AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
val outbox: OutboxStore
|
val outbox: OutboxStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-канал **онлайн-событий** ([OnlineEvent] — стриминг ответа агента).
|
||||||
|
*
|
||||||
|
* Отделён от [outbox] принципиально:
|
||||||
|
* - [outbox] — durable: «целые» события, перезапрашиваемые по курсору
|
||||||
|
* (`after`), с catchup + live;
|
||||||
|
* - [onlineOutbox] — live-only: дельты токенов, **никогда не сохраняются**,
|
||||||
|
* без catchup/курсора, подписка работает только «онлайн».
|
||||||
|
*
|
||||||
|
* Используется HTTP-фасадом `:server` для endpoint'а
|
||||||
|
* `GET /{path}/conversations/{id}/online` (SSE). Клиент рендерит ответ
|
||||||
|
* из этого потока в реальном времени, а durable-историю берёт из [journal].
|
||||||
|
*
|
||||||
|
* **Read-only**: write-доступ только через `MutableOnlineOutbox` внутри
|
||||||
|
* ChatAgent / ConversationLoop, не через [Agent] interface.
|
||||||
|
*/
|
||||||
|
val onlineOutbox: OnlineOutbox
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only view на `conversation` table (id + title + timestamps).
|
* Read-only view на `conversation` table (id + title + timestamps).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Идентификационная информация об [Agent] — публичное представление для
|
||||||
|
* UI/A2A-агентов/IRC/визуальных дашбордов: имя ("как зовут ассистента"),
|
||||||
|
* краткое описание ("что он делает") и зачем он может быть полезен
|
||||||
|
* ("в каких задачах помогает").
|
||||||
|
*
|
||||||
|
* Контрактное отличие от [Agent.id]:
|
||||||
|
* - [Agent.id] — стабильный opaque identifier для корреляции, логов и
|
||||||
|
* транспорта (A2A `contextId`, IRC CTCP-запросы, etc). Менять нельзя.
|
||||||
|
* - [AgentInfo.name] — **человекочитаемое имя**, которое показывается
|
||||||
|
* клиенту и которое пользователь может настроить (например,
|
||||||
|
* `AGENTIK_NAME` в standalone-конфиге). Может меняться.
|
||||||
|
*
|
||||||
|
* Wire-формат (через `:server` JSON-фасад): `name` — обязательное поле,
|
||||||
|
* `description` и `usefulness` — опциональные, могут отсутствовать.
|
||||||
|
*
|
||||||
|
* @property name Человекочитаемое имя ассистента. Обязательное.
|
||||||
|
* @property description Краткое описание (что агент делает/кто он).
|
||||||
|
* Не сериализуется, если `null`.
|
||||||
|
* @property usefulness Зачем агент может быть полезен. Не сериализуется,
|
||||||
|
* если `null`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AgentInfo(
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("usefulness")
|
||||||
|
val usefulness: String? = null,
|
||||||
|
)
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package pw.binom.agentik.proto
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
sealed interface Content {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("text")
|
|
||||||
class Text(val body: String) : Content
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Картинка. [mime] — MIME-тип, например `"image/png"`, `"image/jpeg"`.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
@SerialName("image")
|
|
||||||
class Image(val data: ByteArray, val mime: String) : Content
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@ package pw.binom.agentik.proto
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
|
import pw.binom.agentik.content.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,15 +11,21 @@ import kotlin.time.Instant
|
|||||||
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
|
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
|
||||||
* [Conversation].
|
* [Conversation].
|
||||||
*
|
*
|
||||||
* **Live-события** диалога (turn stream: StartReasoning / AppendText / End /
|
* **Live-события** диалога НЕ часть этого интерфейса. Их два независимых
|
||||||
* ToolCall / ToolResult / ...) НЕ часть этого интерфейса — единственный
|
* потока:
|
||||||
* источник live-событий это [pw.binom.agentik.outbox.OutboxStore].
|
* - **durable** ([pw.binom.agentik.outbox.Event]: UserMessage / AssistantMessage /
|
||||||
* Подписаться на события конкретного диалога:
|
* Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из
|
||||||
* ```
|
* [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору:
|
||||||
* agent.outbox.conversationEvents(after = lastSeen, conversationId = id)
|
* ```
|
||||||
* .map { it.event }
|
* agent.outbox.conversationEvents(after = lastSeen, conversationId = id)
|
||||||
* .collect { e -> ... }
|
* .map { it.event }
|
||||||
* ```
|
* .collect { e -> ... }
|
||||||
|
* ```
|
||||||
|
* - **online** ([pw.binom.agentik.outbox.OnlineEvent]: Working / End /
|
||||||
|
* StartReasoning / StartResponse / AppendText / AppendImage) — live-only
|
||||||
|
* стриминг ответа из [pw.binom.agentik.outbox.OnlineOutbox], без
|
||||||
|
* catchup/курсора: `agent.onlineOutbox.onlineEvents(id)`.
|
||||||
|
*
|
||||||
* Для cross-conversation view (admin / parent-agent / debug):
|
* Для cross-conversation view (admin / parent-agent / debug):
|
||||||
* `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed):
|
* `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed):
|
||||||
* `agent.outbox.agentEvents(after)`.
|
* `agent.outbox.agentEvents(after)`.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package pw.binom.agentik.proto
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
|
import pw.binom.agentik.content.MessageContext
|
||||||
|
import pw.binom.agentik.content.TurnTokens
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -33,7 +36,17 @@ sealed interface Message {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("assistant_message")
|
@SerialName("assistant_message")
|
||||||
class AssistantMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
|
class AssistantMessage(
|
||||||
|
override val id: String,
|
||||||
|
val content: List<Content>,
|
||||||
|
override val date: Instant,
|
||||||
|
val tokens: TurnTokens? = null,
|
||||||
|
/**
|
||||||
|
* Текст размышлений модели (chain-of-thought / reasoning), если
|
||||||
|
* провайдер его отдаёт. Опционально.
|
||||||
|
*/
|
||||||
|
val reasoning: String? = null,
|
||||||
|
) : Message
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("tool_call")
|
@SerialName("tool_call")
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
package pw.binom.agentik.proto
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Кто/что инициировал данный ход сообщения.
|
|
||||||
*
|
|
||||||
* Используется в [MessageContext] — каждый ход диалога может нести
|
|
||||||
* дополнительный контекст о природе триггера:
|
|
||||||
* - [USER] — обычное сообщение от пользователя в чате (дефолт, context=null).
|
|
||||||
* - [SYSTEM] — программное системное сообщение (старт агента, режим обслуживания,
|
|
||||||
* уведомление о завершении фоновой задачи).
|
|
||||||
* - [EVENT] — внешнее событие (cron, webhook, file-changed, и т.п.).
|
|
||||||
* В этом случае [MessageContext.sourceId] и [MessageContext.description]
|
|
||||||
* позволяют модели понять, что за источник её разбудил.
|
|
||||||
*
|
|
||||||
* Семантический контракт:
|
|
||||||
* - origin != USER ⇒ [MessageContext.description] обязателен и должен быть
|
|
||||||
* человекочитаемым (короткая фраза для модели).
|
|
||||||
* - origin == USER ⇒ context может быть `null` (дефолт), и если задан — поля
|
|
||||||
* интерпретируются как «дополнительная мета» (например, ui_client).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
enum class MessageOrigin {
|
|
||||||
@SerialName("user")
|
|
||||||
USER,
|
|
||||||
|
|
||||||
@SerialName("system")
|
|
||||||
SYSTEM,
|
|
||||||
|
|
||||||
@SerialName("event")
|
|
||||||
EVENT,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
|
|
||||||
*
|
|
||||||
* Примеры:
|
|
||||||
* ```
|
|
||||||
* // cron-задача утренней сводки
|
|
||||||
* MessageContext(
|
|
||||||
* origin = MessageOrigin.EVENT,
|
|
||||||
* description = "scheduled cron 'morning-briefing'",
|
|
||||||
* sourceId = "cron-42",
|
|
||||||
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* // обычное сообщение из IRC
|
|
||||||
* MessageContext(
|
|
||||||
* origin = MessageOrigin.USER,
|
|
||||||
* sourceId = "irc-channel:agentik",
|
|
||||||
* description = "PRIVMSG from nick",
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* // старт агента после рестарта
|
|
||||||
* MessageContext(
|
|
||||||
* origin = MessageOrigin.SYSTEM,
|
|
||||||
* description = "agent startup greeting",
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Сериализация: snake_case для стабильного wire-формата ([origin] идёт как
|
|
||||||
* `user`/`system`/`event` благодаря @SerialName на enum).
|
|
||||||
*
|
|
||||||
* Forward-совместимо: добавление новых полей — non-breaking для старых
|
|
||||||
* клиентов, которые их игнорируют.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class MessageContext(
|
|
||||||
val origin: MessageOrigin,
|
|
||||||
/**
|
|
||||||
* Короткая человекочитаемая фраза для LLM: попадает в working memory
|
|
||||||
* как префикс `[origin] description (sourceId=…)` к user-сообщению,
|
|
||||||
* чтобы модель видела, что её разбудил не пользователь, а событие.
|
|
||||||
*/
|
|
||||||
val description: String? = null,
|
|
||||||
/**
|
|
||||||
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC,
|
|
||||||
* id фонового события. Помогает модели и оператору при логировании понять,
|
|
||||||
* откуда пришёл ход.
|
|
||||||
*/
|
|
||||||
val sourceId: String? = null,
|
|
||||||
/**
|
|
||||||
* Произвольный структурированный payload о событии.
|
|
||||||
* Например: `{"scheduledAt": "...", "rule": "..."}` для cron,
|
|
||||||
* или `{"headers": {...}, "ip": "..."}` для webhook.
|
|
||||||
*
|
|
||||||
* Никогда не попадает в LLM-нагрузку как сырой JSON — используется
|
|
||||||
* только для логирования и пост-аналитики.
|
|
||||||
*/
|
|
||||||
val metadata: JsonElement? = null,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сериализация [AgentInfo] — контракт, по которому
|
||||||
|
* `GET {path}` отдаёт JSON клиенту/A2A.
|
||||||
|
*
|
||||||
|
* JSON-форма `description` и `usefulness` исключаются, когда null
|
||||||
|
* (благодаря `explicitNulls = false` в `agentikJson`).
|
||||||
|
*/
|
||||||
|
class AgentInfoSerializationTest {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyName_whenOptionalsAreNull() {
|
||||||
|
val info = AgentInfo(name = "agentik")
|
||||||
|
val s = json.encodeToString(info)
|
||||||
|
assertEquals("""{"name":"agentik"}""", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allFields_whenAllSet() {
|
||||||
|
val info = AgentInfo(
|
||||||
|
name = "Hermes",
|
||||||
|
description = "Ассистент-разработчик на Kotlin",
|
||||||
|
usefulness = "Помогает писать код, искать баги, объяснять legacy",
|
||||||
|
)
|
||||||
|
val s = json.encodeToString(info)
|
||||||
|
assertEquals(
|
||||||
|
"""{"name":"Hermes","description":"Ассистент-разработчик на Kotlin","usefulness":"Помогает писать код, искать баги, объяснять legacy"}""",
|
||||||
|
s,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun descriptionOnly_usefulnessOmitted() {
|
||||||
|
val info = AgentInfo(name = "x", description = "d")
|
||||||
|
val s = json.encodeToString(info)
|
||||||
|
assertEquals("""{"name":"x","description":"d"}""", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun usefulnessOnly_descriptionOmitted() {
|
||||||
|
val info = AgentInfo(name = "x", usefulness = "u")
|
||||||
|
val s = json.encodeToString(info)
|
||||||
|
assertEquals("""{"name":"x","usefulness":"u"}""", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTrip_preservesFields() {
|
||||||
|
val original = AgentInfo(name = "Hermes", description = "d", usefulness = "u")
|
||||||
|
val decoded = json.decodeFromString<AgentInfo>(json.encodeToString(original))
|
||||||
|
assertEquals(original, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultConstructor_optionalsAreNull() {
|
||||||
|
val info = AgentInfo(name = "x")
|
||||||
|
assertNull(info.description)
|
||||||
|
assertNull(info.usefulness)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ plugins {
|
|||||||
// других ksqlite-модулях; этот модуль не претендует на полную схему агента.
|
// других ksqlite-модулях; этот модуль не претендует на полную схему агента.
|
||||||
//
|
//
|
||||||
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
// на Linux (ksqlite 0.1.2 не публикует native артефакты для Apple).
|
// на Linux (ksqlite не публикует native артефакты для Apple).
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
@@ -20,9 +20,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
// ksqlite живёт в libs.versions.toml (см. catalog) — обычный
|
||||||
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
implementation("pw.binom.db:ksqlite:0.1.2")
|
implementation(libs.ksqlite)
|
||||||
|
|
||||||
api(project(":reflection-api"))
|
api(project(":reflection-api"))
|
||||||
// :reflection-api ссылается на :journal-api типы в сигнатурах
|
// :reflection-api ссылается на :journal-api типы в сигнатурах
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ kotlin {
|
|||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
implementation(libs.ktor.server.test.host)
|
implementation(libs.ktor.server.test.host)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(project(":journal-inmemory"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package pw.binom.agentik.server
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import pw.binom.agentik.journal.JournalStore
|
|||||||
* project'нутые proto-[pw.binom.agentik.proto.Message]), здесь клиент
|
* project'нутые proto-[pw.binom.agentik.proto.Message]), здесь клиент
|
||||||
* получает полный transcript с tool-call/tool-result/error payload-ами,
|
* получает полный transcript с tool-call/tool-result/error payload-ами,
|
||||||
* turn-tokens и context-метаданными.
|
* turn-tokens и context-метаданными.
|
||||||
|
* - `GET /conversations/{id}/count?after=` — сколько сообщений в диалоге
|
||||||
|
* всего (без `after`) или строго позже `after` (с `after`). Лёгкий
|
||||||
|
* endpoint для UI-бейджей "N новых сообщений" и compaction-метрик;
|
||||||
|
* тело ответа — JSON `{"count": <Long>}`.
|
||||||
*
|
*
|
||||||
* **Read-only:** [JournalStore] не имеет `append` — запись только через
|
* **Read-only:** [JournalStore] не имеет `append` — запись только через
|
||||||
* writer-референс, который ChatAgent держит внутри (тип `MutableJournalStore`,
|
* writer-референс, который ChatAgent держит внутри (тип `MutableJournalStore`,
|
||||||
@@ -40,5 +44,19 @@ fun Route.journalRoutes(
|
|||||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
|
||||||
call.respond(journal.list(id, after, offset, limit))
|
call.respond(journal.list(id, after, offset, limit))
|
||||||
}
|
}
|
||||||
|
get("/conversations/{id}/count") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val after = call.parseAfter()
|
||||||
|
val count = if (after == null) {
|
||||||
|
journal.count(id)
|
||||||
|
} else {
|
||||||
|
journal.count(id, after)
|
||||||
|
}
|
||||||
|
call.respond(CountResponse(count = count))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Тело ответа `GET /conversations/{id}/count`. */
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
private data class CountResponse(val count: Long)
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ 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.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.outbox.AgentEvent
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.outbox.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
internal fun Route.agentikRoutes(agent: Agent) {
|
internal fun Route.agentikRoutes(agent: Agent) {
|
||||||
@@ -33,6 +34,19 @@ internal fun Route.agentikRoutes(agent: Agent) {
|
|||||||
call.respondText("ok")
|
call.respondText("ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET {path}` — публичная информация об агенте (`name` +
|
||||||
|
* опциональные `description` / `usefulness`). Контрактно отличается
|
||||||
|
* от `/health` тем, что отдаёт человекочитаемые данные об агенте,
|
||||||
|
* а не просто "alive"-сигнал.
|
||||||
|
*
|
||||||
|
* Используется UI-дашбордами / A2A-fetch'ерами AgentCard для
|
||||||
|
* отображения карточки агента без подписки на события.
|
||||||
|
*/
|
||||||
|
get("") {
|
||||||
|
call.respond(agent.info)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Agent: множество диалогов ----
|
// ---- Agent: множество диалогов ----
|
||||||
|
|
||||||
get("/conversations") {
|
get("/conversations") {
|
||||||
@@ -54,6 +68,25 @@ internal fun Route.agentikRoutes(agent: Agent) {
|
|||||||
else call.respond(c.snapshot())
|
else call.respond(c.snapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /conversations/{id}/record` — лёгкая метадата записи
|
||||||
|
* ([pw.binom.agentik.journal.ConversationRecord]: id/title/isTemporal/
|
||||||
|
* createdAt/updatedAt) БЕЗ handle'а и image-support флагов.
|
||||||
|
*
|
||||||
|
* Отдельно от `GET /conversations/{id}` (который отдаёт
|
||||||
|
* [pw.binom.agentik.proto.ConversationSnapshot] — его ждёт
|
||||||
|
* `AgentClient.getConversation`): это тот же shape, что и элемент
|
||||||
|
* списка `GET /conversations`, его читает клиентский кэш
|
||||||
|
* (`HttpConversationStore.get`), чтобы дотянуть запись после
|
||||||
|
* outbox-события `AgentEvent.Created`.
|
||||||
|
*/
|
||||||
|
get("/conversations/{id}/record") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val rec = agent.conversationStore.get(id)
|
||||||
|
if (rec == null) call.respond(HttpStatusCode.NotFound)
|
||||||
|
else call.respond(rec)
|
||||||
|
}
|
||||||
|
|
||||||
delete("/conversations/{id}") {
|
delete("/conversations/{id}") {
|
||||||
val id = call.parameters["id"]!!
|
val id = call.parameters["id"]!!
|
||||||
call.respond(if (agent.deleteConversation(id)) HttpStatusCode.NoContent else HttpStatusCode.NotFound)
|
call.respond(if (agent.deleteConversation(id)) HttpStatusCode.NoContent else HttpStatusCode.NotFound)
|
||||||
@@ -135,6 +168,36 @@ internal fun Route.agentikRoutes(agent: Agent) {
|
|||||||
call.streamJsonSse(agent.outbox.conversationEvents(after, id).map { it.event }, Event.serializer())
|
call.streamJsonSse(agent.outbox.conversationEvents(after, id).map { it.event }, Event.serializer())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /conversations/{id}/online` — **live-only** SSE поток
|
||||||
|
* онлайн-событий ([pw.binom.agentik.outbox.OnlineEvent]): дельты
|
||||||
|
* текста/картинок стриминга ответа.
|
||||||
|
*
|
||||||
|
* Отличия от `/conversations/{id}/events`:
|
||||||
|
* - нет `after` — catchup невозможен, события не хранятся;
|
||||||
|
* - подписка получает только то, что эмитится после подключения;
|
||||||
|
* - потерянные дельты восстанавливаются из durable-истории
|
||||||
|
* (`/events` + журнал), а не реплеятся здесь.
|
||||||
|
*/
|
||||||
|
get("/conversations/{id}/online") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val c = agent.getConversation(id)
|
||||||
|
if (c == null) {
|
||||||
|
call.respond(HttpStatusCode.NotFound)
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
call.streamJsonSse(agent.onlineOutbox.onlineEvents(id), OnlineEvent.serializer())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /online` — **live-only** SSE поток онлайн-событий всех диалогов
|
||||||
|
* агента (аналог `/events/all` для durable). Без `after`: онлайн-события
|
||||||
|
* не хранятся и не реплеятся.
|
||||||
|
*/
|
||||||
|
get("/online") {
|
||||||
|
call.streamJsonSse(agent.onlineOutbox.onlineEvents(), OnlineEvent.serializer())
|
||||||
|
}
|
||||||
|
|
||||||
get("/events") {
|
get("/events") {
|
||||||
val after = call.parseAfter() ?: return@get
|
val after = call.parseAfter() ?: return@get
|
||||||
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
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 kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционные тесты endpoint'а `GET {path}` (без суффикса) — отдаёт
|
||||||
|
* публичную информацию об агенте ([AgentInfo]) для UI/A2A/IRC.
|
||||||
|
*
|
||||||
|
* Проверяет:
|
||||||
|
* - happy path с полным [AgentInfo] (name + description + usefulness);
|
||||||
|
* - минимальный [AgentInfo] (только name, optional поля null/отсутствуют);
|
||||||
|
* - JSON-shape: только присутствующие в [AgentInfo] поля, `null`-опциональные
|
||||||
|
* выкинуты (благодаря `explicitNulls = false` в `agentikJson`);
|
||||||
|
* - endpoint доступен без авторизации (auth отключён в этом тесте;
|
||||||
|
* bearer-token тест уже в [BearerTokenTest]).
|
||||||
|
*/
|
||||||
|
class AgentInfoRouteTest {
|
||||||
|
|
||||||
|
private lateinit var server: EmbeddedServer<*, *>
|
||||||
|
private var port: Int = 0
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class InfoDto(val name: String, val description: String? = null, val usefulness: String? = null)
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
val fake = object : Agent {
|
||||||
|
override val id: String = "test-agent"
|
||||||
|
override val info: AgentInfo = AgentInfo(
|
||||||
|
name = "Hermes",
|
||||||
|
description = "Kotlin-ассистент для agentik",
|
||||||
|
usefulness = "Помогает писать код и искать баги",
|
||||||
|
)
|
||||||
|
override val journal: JournalStore = object : JournalStore {
|
||||||
|
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
|
||||||
|
override suspend fun count(conversationId: String): Long = 0L
|
||||||
|
override suspend fun count(conversationId: String, after: Instant): Long = 0L
|
||||||
|
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 val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
|
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||||
|
override suspend fun get(id: String) = null
|
||||||
|
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||||
|
override suspend fun getConversation(id: String): Conversation? = null
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean = false
|
||||||
|
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
|
||||||
|
routing { agentikAgent(fake, path = "/agentik", token = null) }
|
||||||
|
}.start(wait = false)
|
||||||
|
port = runBlocking { server.engine.resolvedConnectors()[0].port }
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(): HttpClient = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun get_returns_full_info_as_json() = runBlocking {
|
||||||
|
val response = client().get("http://127.0.0.1:$port/agentik")
|
||||||
|
assertEquals(HttpStatusCode.OK, response.status)
|
||||||
|
val dto = response.body<InfoDto>()
|
||||||
|
assertEquals(InfoDto("Hermes", "Kotlin-ассистент для agentik", "Помогает писать код и искать баги"), dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun get_body_shape_has_all_three_fields() = runBlocking {
|
||||||
|
val response = client().get("http://127.0.0.1:$port/agentik")
|
||||||
|
val raw = response.bodyAsText()
|
||||||
|
// explicitNulls=false → все три поля всегда присутствуют у full-info agent'а.
|
||||||
|
assertEquals(
|
||||||
|
"""{"name":"Hermes","description":"Kotlin-ассистент для agentik","usefulness":"Помогает писать код и искать баги"}""",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun get_returns_minimal_info_when_only_name_set() {
|
||||||
|
// override agent: minimal info (только name).
|
||||||
|
// Storage handles — noop-stub'ы (см. BearerTokenTest: agentikAgent
|
||||||
|
// вычитывает их eagerly при старте).
|
||||||
|
val minimal = object : Agent {
|
||||||
|
override val id: String = "minimal"
|
||||||
|
override val info: AgentInfo = AgentInfo(name = "agentik")
|
||||||
|
override val journal: JournalStore = object : JournalStore {
|
||||||
|
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
|
||||||
|
override suspend fun count(conversationId: String): Long = 0L
|
||||||
|
override suspend fun count(conversationId: String, after: Instant): Long = 0L
|
||||||
|
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 val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
|
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||||
|
override suspend fun get(id: String) = null
|
||||||
|
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
|
||||||
|
override suspend fun getConversation(id: String): Conversation? = null
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean = false
|
||||||
|
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
val server2 = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
|
||||||
|
routing { agentikAgent(minimal, path = "/agentik", token = null) }
|
||||||
|
}.start(wait = false)
|
||||||
|
try {
|
||||||
|
val p = runBlocking { server2.engine.resolvedConnectors()[0].port }
|
||||||
|
val raw = runBlocking { client().get("http://127.0.0.1:$p/agentik").bodyAsText() }
|
||||||
|
// explicitNulls=false выкидывает null-опциональные поля → только {"name":"agentik"}.
|
||||||
|
assertEquals("""{"name":"agentik"}""", raw)
|
||||||
|
} finally {
|
||||||
|
server2.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ import pw.binom.agentik.journal.ConversationStore
|
|||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
@@ -41,9 +43,12 @@ class BearerTokenTest {
|
|||||||
*/
|
*/
|
||||||
private class FakeAgent(
|
private class FakeAgent(
|
||||||
override val id: String = "test",
|
override val id: String = "test",
|
||||||
|
override val info: AgentInfo = AgentInfo(name = "test"),
|
||||||
) : Agent {
|
) : Agent {
|
||||||
override val journal: JournalStore = object : JournalStore {
|
override val journal: JournalStore = object : JournalStore {
|
||||||
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
|
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
|
||||||
|
override suspend fun count(conversationId: String): Long = 0L
|
||||||
|
override suspend fun count(conversationId: String, after: Instant): Long = 0L
|
||||||
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
|
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
@@ -54,6 +59,7 @@ class BearerTokenTest {
|
|||||||
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
}
|
}
|
||||||
|
override val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
override val conversationStore: ConversationStore = object : ConversationStore {
|
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||||
override suspend fun get(id: String) = null
|
override suspend fun get(id: String) = null
|
||||||
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
|
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
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.ConversationRecord
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты `GET /conversations` и `GET /conversations/{id}/record` — обе отдают
|
||||||
|
* [ConversationRecord] (лёгкую метадату элемента списка).
|
||||||
|
*
|
||||||
|
* Это **регрессия** на два бага, из-за которых клиентский
|
||||||
|
* `wrapWithLocalConversationCache` не наполнялся:
|
||||||
|
* 1. [ConversationRecord] не был `@Serializable` → `GET /conversations`
|
||||||
|
* падал 500 `Serializer for class ... not found`;
|
||||||
|
* 2. клиентский `HttpConversationStore.get` читал `ConversationRecord` из
|
||||||
|
* `GET /conversations/{id}`, который отдаёт `ConversationSnapshot`
|
||||||
|
* (другой shape: handle + isImageSupported, без createdAt/updatedAt)
|
||||||
|
* → `MissingFieldException: Field 'createdAt' is required`. Для этого
|
||||||
|
* заведён отдельный `/record`-endpoint.
|
||||||
|
*/
|
||||||
|
class ConversationRoutesTest {
|
||||||
|
|
||||||
|
private lateinit var server: EmbeddedServer<*, *>
|
||||||
|
private lateinit var journal: InMemoryJournalStore
|
||||||
|
private lateinit var store: InMemoryMutableConversationStore
|
||||||
|
private var port: Int = 0
|
||||||
|
|
||||||
|
private val t0 = Instant.parse("2026-09-22T10:00:00Z")
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
journal = InMemoryJournalStore()
|
||||||
|
store = InMemoryMutableConversationStore()
|
||||||
|
val fakeAgent = FakeAgent(journal, store)
|
||||||
|
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
|
||||||
|
routing { agentikAgent(fakeAgent, path = "/agentik", token = null) }
|
||||||
|
}.start(wait = false)
|
||||||
|
port = runBlocking { server.engine.resolvedConnectors()[0].port }
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
server.stop(100, 200)
|
||||||
|
journal.close()
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(): HttpClient = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seed(id: String, title: String?, isTemporal: Boolean = false, updatedAt: Instant = t0) =
|
||||||
|
runBlocking {
|
||||||
|
store.upsert(ConversationRecord(id, title, isTemporal, t0, updatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list returns serializable conversation records`() = runBlocking {
|
||||||
|
seed("c1", "Первый", updatedAt = t0)
|
||||||
|
seed("c2", null, isTemporal = true, updatedAt = t0 + 1.seconds)
|
||||||
|
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
// До фикса `@Serializable` здесь был 500.
|
||||||
|
val records = resp.body<List<ConversationRecord>>()
|
||||||
|
// Сортировка хранилища — updatedAt DESC.
|
||||||
|
assertEquals(listOf("c2", "c1"), records.map { it.id })
|
||||||
|
assertEquals(listOf(null, "Первый"), records.map { it.title })
|
||||||
|
assertEquals(Instant.parse("2026-09-22T10:00:01Z"), records[0].updatedAt)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list body carries timestamps that the client cache needs`() = runBlocking {
|
||||||
|
seed("c1", "Первый")
|
||||||
|
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val raw = client.get("http://127.0.0.1:$port/agentik/conversations").bodyAsText()
|
||||||
|
assertEquals(
|
||||||
|
"""[{"id":"c1","title":"Первый","isTemporal":false,""" +
|
||||||
|
""""createdAt":"2026-09-22T10:00:00Z","updatedAt":"2026-09-22T10:00:00Z"}]""",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `record returns conversation record by id`() = runBlocking {
|
||||||
|
seed("c1", "Первый", isTemporal = true)
|
||||||
|
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations/c1/record")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
val rec = assertNotNull(resp.body<ConversationRecord>())
|
||||||
|
assertEquals("c1", rec.id)
|
||||||
|
assertEquals("Первый", rec.title)
|
||||||
|
assertEquals(true, rec.isTemporal)
|
||||||
|
assertEquals(t0, rec.createdAt)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `record for unknown conversation returns not found`() = runBlocking {
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations/never-existed/record")
|
||||||
|
assertEquals(HttpStatusCode.NotFound, resp.status)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный stub-агент: реальные [JournalStore] и
|
||||||
|
* [InMemoryMutableConversationStore]; outbox — noop, conversation-хендлы
|
||||||
|
* (`getConversation`/`createConversation`) тесту не нужны.
|
||||||
|
*/
|
||||||
|
private class FakeAgent(
|
||||||
|
private val js: JournalStore,
|
||||||
|
private val cs: ConversationStore,
|
||||||
|
) : Agent {
|
||||||
|
override val id: String = "test"
|
||||||
|
override val info: AgentInfo = AgentInfo(name = "test")
|
||||||
|
override val journal: JournalStore = js
|
||||||
|
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 val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
|
override val conversationStore: ConversationStore = cs
|
||||||
|
|
||||||
|
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
|
||||||
|
TODO("not used")
|
||||||
|
|
||||||
|
override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean = false
|
||||||
|
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
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.serialization.json.Json
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.content.Content as JContent
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционные тесты нового `GET /journal/conversations/{id}/count`-endpoint'а
|
||||||
|
* в [journalRoutes].
|
||||||
|
*
|
||||||
|
* Поднимается embedded CIO-сервер с реальным [InMemoryJournalStore], пишутся
|
||||||
|
* `UserMessage` через [pw.binom.agentik.journal.MutableJournalStore.append],
|
||||||
|
* затем дёргаются оба варианта (с/без `after`) и проверяются JSON-shape +
|
||||||
|
* семантика.
|
||||||
|
*/
|
||||||
|
class JournalRoutesCountTest {
|
||||||
|
|
||||||
|
private lateinit var server: EmbeddedServer<*, *>
|
||||||
|
private lateinit var journal: InMemoryJournalStore
|
||||||
|
private var port: Int = 0
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CountResponse(val count: Long)
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
journal = InMemoryJournalStore()
|
||||||
|
val fakeAgent = FakeAgent(journal)
|
||||||
|
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
|
||||||
|
routing { agentikAgent(fakeAgent, path = "/agentik", token = null) }
|
||||||
|
}.start(wait = false)
|
||||||
|
port = runBlocking { server.engine.resolvedConnectors()[0].port }
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
server.stop(100, 200)
|
||||||
|
journal.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun append(id: String, convId: String, text: String, at: Instant) {
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
journal.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
content = listOf(JContent.Text(text)),
|
||||||
|
createdAt = at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(): HttpClient = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count without after returns total in conversation`() = runBlocking {
|
||||||
|
val t0 = Instant.parse("2026-09-22T10:00:00Z")
|
||||||
|
append("m1", "c1", "a", t0)
|
||||||
|
append("m2", "c1", "b", t0 + 1.seconds)
|
||||||
|
append("m3", "c2", "c", t0 + 2.seconds)
|
||||||
|
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/journal/conversations/c1/count")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
assertEquals(CountResponse(count = 2L), resp.body<CountResponse>())
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count with after returns strictly-after count`() = runBlocking {
|
||||||
|
val t0 = Instant.parse("2026-09-22T10:00:00Z")
|
||||||
|
append("m1", "c1", "a", t0)
|
||||||
|
append("m2", "c1", "b", t0 + 10.seconds)
|
||||||
|
append("m3", "c1", "c", t0 + 20.seconds)
|
||||||
|
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/journal/conversations/c1/count") {
|
||||||
|
parameter("after", t0.toString())
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
// strictly > t0 → m2, m3
|
||||||
|
assertEquals(CountResponse(count = 2L), resp.body<CountResponse>())
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count for unknown conversation returns zero`() = runBlocking {
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/journal/conversations/never-existed/count")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
assertEquals(CountResponse(count = 0L), resp.body<CountResponse>())
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count body is JSON with count key`() = runBlocking {
|
||||||
|
val t0 = Instant.parse("2026-09-22T10:00:00Z")
|
||||||
|
append("m1", "c1", "x", t0)
|
||||||
|
append("m2", "c1", "y", t0 + 1.seconds)
|
||||||
|
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/journal/conversations/c1/count")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
val raw = resp.bodyAsText()
|
||||||
|
// Сырое JSON-тело — стабильный shape, не зависит от сериализатора.
|
||||||
|
assertEquals("""{"count":2}""", raw)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `count isolates by conversation`() = runBlocking {
|
||||||
|
val t = Instant.parse("2026-09-22T10:00:00Z")
|
||||||
|
for (i in 1..5) append("m$i", "c1", "x", t + i.seconds)
|
||||||
|
append("n1", "c2", "y", t)
|
||||||
|
|
||||||
|
val client = client()
|
||||||
|
try {
|
||||||
|
val r1 = client.get("http://127.0.0.1:$port/agentik/journal/conversations/c1/count").body<CountResponse>()
|
||||||
|
val r2 = client.get("http://127.0.0.1:$port/agentik/journal/conversations/c2/count").body<CountResponse>()
|
||||||
|
assertEquals(5L, r1.count)
|
||||||
|
assertEquals(1L, r2.count)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный stub-агент: только [JournalStore] нужен для теста
|
||||||
|
* `journalRoutes`. Outbox/ConversationStore — noop-объекты,
|
||||||
|
* чтобы старт [agentikAgent] не валился.
|
||||||
|
*/
|
||||||
|
private class FakeAgent(private val js: JournalStore) : Agent {
|
||||||
|
override val id: String = "test"
|
||||||
|
override val info: AgentInfo = AgentInfo(name = "test")
|
||||||
|
override val journal: JournalStore = js
|
||||||
|
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 val onlineOutbox: OnlineOutbox = emptyOnlineOutbox()
|
||||||
|
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||||
|
override suspend fun get(id: String) = null
|
||||||
|
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
|
||||||
|
TODO("not used")
|
||||||
|
override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean = false
|
||||||
|
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пустой [OnlineOutbox] для тестовых [pw.binom.agentik.proto.Agent]-заглушек:
|
||||||
|
* онлайн-канал тестам не нужен, но интерфейс обязывает его отдать.
|
||||||
|
*/
|
||||||
|
internal fun emptyOnlineOutbox(): OnlineOutbox = object : OnlineOutbox {
|
||||||
|
override fun onlineEvents() = emptyFlow<OnlineEvent>()
|
||||||
|
override fun onlineEvents(conversationId: String) = emptyFlow<OnlineEvent>()
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
@@ -79,6 +79,12 @@ include(":reflection-api")
|
|||||||
include(":journal-api")
|
include(":journal-api")
|
||||||
include(":context-api")
|
include(":context-api")
|
||||||
include(":outbox-api")
|
include(":outbox-api")
|
||||||
|
// Общие типы содержимого сообщения (Content/MessageContext/MessageOrigin/
|
||||||
|
// TurnTokens). Вынесены в отдельный низкоуровневый модуль, чтобы ими
|
||||||
|
// пользовались И :proto, И :journal-api, И :outbox-api (message-события
|
||||||
|
// несут List<Content> + MessageContext) без дублирования типов и циклов
|
||||||
|
// в графе зависимостей.
|
||||||
|
include(":content-api")
|
||||||
// Bounded-tail event log с auto-TTL. Двухуровневое хранилище: этот модуль —
|
// Bounded-tail event log с auto-TTL. Двухуровневое хранилище: этот модуль —
|
||||||
// короткий live tail + recent replay; полный audit log живёт в :message-store-api
|
// короткий live tail + recent replay; полный audit log живёт в :message-store-api
|
||||||
// (там — MessageStore + ConversationStore). EventStore сам управляет eviction,
|
// (там — MessageStore + ConversationStore). EventStore сам управляет eviction,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
Главный исполняемый модуль проекта — single-jar HTTP-сервер с:
|
Главный исполняемый модуль проекта — single-jar HTTP-сервер с:
|
||||||
|
|
||||||
- **AG-UI** transport на `POST /agui` (SSE) + `GET /health`.
|
|
||||||
- **A2A** transport на `POST /` (JSON-RPC) + `GET /.well-known/agent-card.json`.
|
- **A2A** transport на `POST /` (JSON-RPC) + `GET /.well-known/agent-card.json`.
|
||||||
- **`:proto`** transport на `POST /agentik/*` (HTTP+JSON+SSE) — наш stateful.
|
- **`:proto`** transport на `POST /agentik/*` (HTTP+JSON+SSE) — наш stateful.
|
||||||
- **Embedded LLM backend**: `GOOGLE` (LiteRT) или `OPENAI`-совместимый
|
- **Embedded LLM backend**: `GOOGLE` (LiteRT) или `OPENAI`-совместимый
|
||||||
@@ -98,7 +97,6 @@ AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
|||||||
| Метод | Путь | Transport | Описание |
|
| Метод | Путь | Transport | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/health` | любой | health-check (`{"ok":true}`) |
|
| `GET` | `/health` | любой | health-check (`{"ok":true}`) |
|
||||||
| `POST` | `/agui` | AG-UI | Стриминг run (SSE) |
|
|
||||||
| `POST` | `/` | A2A | JSON-RPC `message/send`, `tasks/get`, `tasks/cancel` |
|
| `POST` | `/` | A2A | JSON-RPC `message/send`, `tasks/get`, `tasks/cancel` |
|
||||||
| `GET` | `/.well-known/agent-card.json` | A2A | Discovery |
|
| `GET` | `/.well-known/agent-card.json` | A2A | Discovery |
|
||||||
| `POST` | `/agentik/conversations` | :proto | Создать диалог |
|
| `POST` | `/agentik/conversations` | :proto | Создать диалог |
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ kotlin {
|
|||||||
// ksqlite объявлен как `implementation` (не `api`) в каждом
|
// ksqlite объявлен как `implementation` (не `api`) в каждом
|
||||||
// ksqlite-модуле, поэтому SqliteStores здесь использует
|
// ksqlite-модуле, поэтому SqliteStores здесь использует
|
||||||
// SQLiteConnection напрямую — фиксируем зависимость явно.
|
// SQLiteConnection напрямую — фиксируем зависимость явно.
|
||||||
implementation("pw.binom.db:ksqlite:0.1.2")
|
implementation(libs.ksqlite)
|
||||||
|
|
||||||
// Bounded-tail live event stream + per-event TTL.
|
// Bounded-tail live event stream + per-event TTL.
|
||||||
implementation(project(":outbox-inmemory"))
|
implementation(project(":outbox-inmemory"))
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ 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.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
@@ -27,9 +28,10 @@ private val log = KotlinLogging.logger {}
|
|||||||
* -> новый диалог, контекст пересоздаётся.
|
* -> новый диалог, контекст пересоздаётся.
|
||||||
* - id внутреннего диалога отдаётся клиенту в `metadata.agentikConversationId` ответа.
|
* - id внутреннего диалога отдаётся клиенту в `metadata.agentikConversationId` ответа.
|
||||||
*
|
*
|
||||||
* Ответ A2A = склеенные [Event.AppendText] нашего хода. Подписку на [Conversation.events]
|
* Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток
|
||||||
* открываем ДО [Conversation.send] (иначе события начала хода могут быть упущены),
|
* ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only,
|
||||||
* завершение хода ждём по [Event.End] / [Event.Interrupted] / [Event.Error].
|
* без catchup — события начала хода иначе можно упустить), завершение хода ждём
|
||||||
|
* по онлайн [OnlineEvent.End] и durable [Event.AssistantMessage] / [Event.Interrupted] / [Event.Error].
|
||||||
*
|
*
|
||||||
* Ограничение v1: tool-события и картинки в A2A-ответ не транслируются;
|
* Ограничение v1: tool-события и картинки в A2A-ответ не транслируются;
|
||||||
* при нескольких ходов в очереди за контекстом текст предыдущего хода
|
* при нескольких ходов в очереди за контекстом текст предыдущего хода
|
||||||
@@ -48,17 +50,27 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
|||||||
val since = conv.updatedAt
|
val since = conv.updatedAt
|
||||||
val reply = StringBuilder()
|
val reply = StringBuilder()
|
||||||
val turnDone = CompletableDeferred<Unit>()
|
val turnDone = CompletableDeferred<Unit>()
|
||||||
val subscription = async {
|
// Онлайн-поток — дельты ответа (live-only, без catchup).
|
||||||
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
val onlineJob = async {
|
||||||
val e = ce.event
|
agent.onlineOutbox.onlineEvents(conv.id).collect { e ->
|
||||||
when (e) {
|
when (e) {
|
||||||
is Event.AppendText -> reply.append(e.body)
|
is OnlineEvent.AppendText -> reply.append(e.body)
|
||||||
is Event.End, is Event.Interrupted -> turnDone.complete(Unit)
|
is OnlineEvent.End -> turnDone.complete(Unit)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Durable-поток — терминатор хода (catchup + live): целый ответ
|
||||||
|
// приходит [Event.AssistantMessage], обрыв — [Event.Interrupted],
|
||||||
|
// провал — [Event.Error].
|
||||||
|
val turnJob = async {
|
||||||
|
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
||||||
|
when (val e = ce.event) {
|
||||||
|
is Event.AssistantMessage, is Event.Interrupted -> turnDone.complete(Unit)
|
||||||
is Event.Error ->
|
is Event.Error ->
|
||||||
if (!turnDone.completeExceptionally(
|
turnDone.completeExceptionally(
|
||||||
IllegalStateException("agent turn failed: ${e.message}")
|
IllegalStateException("agent turn failed: ${e.message}")
|
||||||
)
|
)
|
||||||
) {}
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +79,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
|||||||
try {
|
try {
|
||||||
turnDone.await()
|
turnDone.await()
|
||||||
} finally {
|
} finally {
|
||||||
subscription.cancel()
|
onlineJob.cancel()
|
||||||
|
turnJob.cancel()
|
||||||
}
|
}
|
||||||
log.info { "a2a context=$contextId conv=${conv.id} reply=${reply.length} chars" }
|
log.info { "a2a context=$contextId conv=${conv.id} reply=${reply.length} chars" }
|
||||||
Message(
|
Message(
|
||||||
|
|||||||
@@ -137,5 +137,5 @@ internal suspend fun recentTurns(workingMemoryStore: ContextStore, conversationI
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
|
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
|
||||||
internal fun List<pw.binom.agentik.journal.Content>.text(): String =
|
internal fun List<pw.binom.agentik.content.Content>.text(): String =
|
||||||
filterIsInstance<pw.binom.agentik.journal.Content.Text>().joinToString("\n") { it.body }
|
filterIsInstance<pw.binom.agentik.content.Content.Text>().joinToString("\n") { it.body }
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import pw.binom.agentik.mcp.bridge.McpRegistry
|
|||||||
import pw.binom.agentik.standalone.persistence.SqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import pw.binom.agentik.skill.mining.SkillMiner
|
import pw.binom.agentik.skill.mining.SkillMiner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* standalone-контейнер agentik:
|
* standalone-контейнер agentik:
|
||||||
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
||||||
@@ -67,10 +68,12 @@ fun main(args: Array<String>) {
|
|||||||
runPullModel(args.drop(1))
|
runPullModel(args.drop(1))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
"--help", "-h", "help" -> {
|
"--help", "-h", "help" -> {
|
||||||
printHelp()
|
printHelp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
System.err.println("Unknown subcommand: ${args[0]}")
|
System.err.println("Unknown subcommand: ${args[0]}")
|
||||||
printHelp()
|
printHelp()
|
||||||
@@ -82,11 +85,13 @@ fun main(args: Array<String>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun printHelp() {
|
private fun printHelp() {
|
||||||
println("""
|
println(
|
||||||
|
"""
|
||||||
agentik standalone — usage:
|
agentik standalone — usage:
|
||||||
java -jar agentik.jar Start HTTP server (AGUI + A2A + :proto)
|
java -jar agentik.jar Start HTTP server (A2A + :proto)
|
||||||
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
|
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
|
||||||
""".trimIndent())
|
""".trimIndent()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,6 +250,7 @@ private fun runServer() {
|
|||||||
println(" memory: disabled")
|
println(" memory: disabled")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
MemoryBackend.MD -> {
|
MemoryBackend.MD -> {
|
||||||
if (rawMemory.equals("off", ignoreCase = true)) {
|
if (rawMemory.equals("off", ignoreCase = true)) {
|
||||||
println(" memory: disabled (memoryDir=off)")
|
println(" memory: disabled (memoryDir=off)")
|
||||||
@@ -256,6 +262,7 @@ private fun runServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MemoryBackend.VECTOR -> {
|
MemoryBackend.VECTOR -> {
|
||||||
val embedding: pw.binom.agentik.memory.TextEmbeddingExecutor = when (config.embedding.backend) {
|
val embedding: pw.binom.agentik.memory.TextEmbeddingExecutor = when (config.embedding.backend) {
|
||||||
AppConfig.EmbeddingBackend.HTTP -> {
|
AppConfig.EmbeddingBackend.HTTP -> {
|
||||||
@@ -274,6 +281,7 @@ private fun runServer() {
|
|||||||
dimension = config.embedding.dimension,
|
dimension = config.embedding.dimension,
|
||||||
).asExecutor()
|
).asExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
AppConfig.EmbeddingBackend.SIGLIP -> {
|
AppConfig.EmbeddingBackend.SIGLIP -> {
|
||||||
val modelPath = checkNotNull(config.embedding.modelPath) {
|
val modelPath = checkNotNull(config.embedding.modelPath) {
|
||||||
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
|
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
|
||||||
@@ -291,12 +299,14 @@ private fun runServer() {
|
|||||||
val backendLabel = when (config.embedding.backend) {
|
val backendLabel = when (config.embedding.backend) {
|
||||||
AppConfig.EmbeddingBackend.HTTP ->
|
AppConfig.EmbeddingBackend.HTTP ->
|
||||||
"model=${config.embedding.model}, dim=${config.embedding.dimension}"
|
"model=${config.embedding.model}, dim=${config.embedding.dimension}"
|
||||||
|
|
||||||
AppConfig.EmbeddingBackend.SIGLIP ->
|
AppConfig.EmbeddingBackend.SIGLIP ->
|
||||||
"model=siglip2-base (on-device), dim=${embedding.dimension}"
|
"model=siglip2-base (on-device), dim=${embedding.dimension}"
|
||||||
}
|
}
|
||||||
println(" memory: db=${config.agent.dbPath} (vector-backend, $backendLabel)")
|
println(" memory: db=${config.agent.dbPath} (vector-backend, $backendLabel)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MemoryBackend.HYBRID -> {
|
MemoryBackend.HYBRID -> {
|
||||||
// Гибрид: .md файлы (single source of truth) + ksqlite vector-кэш.
|
// Гибрид: .md файлы (single source of truth) + ksqlite vector-кэш.
|
||||||
// Нужны embeddings (HTTP/SIGLIP) + путь к vector-БД (по умолчанию рядом с agentik.db).
|
// Нужны embeddings (HTTP/SIGLIP) + путь к vector-БД (по умолчанию рядом с agentik.db).
|
||||||
@@ -314,6 +324,7 @@ private fun runServer() {
|
|||||||
dimension = config.embedding.dimension,
|
dimension = config.embedding.dimension,
|
||||||
).asExecutor()
|
).asExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
AppConfig.EmbeddingBackend.SIGLIP -> {
|
AppConfig.EmbeddingBackend.SIGLIP -> {
|
||||||
val modelPath = checkNotNull(config.embedding.modelPath) {
|
val modelPath = checkNotNull(config.embedding.modelPath) {
|
||||||
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
|
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
|
||||||
@@ -363,6 +374,7 @@ private fun runServer() {
|
|||||||
|
|
||||||
val agent = ChatAgent(
|
val agent = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
|
info = config.agent.toAgentInfo(),
|
||||||
mutableConversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
messageStore = sqliteStores.messages,
|
messageStore = sqliteStores.messages,
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ 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.AgentInfo
|
||||||
import pw.binom.agentik.outbox.AgentEvent
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
import pw.binom.agentik.outbox.Event as OutboxEvent
|
import pw.binom.agentik.outbox.Event as OutboxEvent
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
|
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||||
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
@@ -83,6 +86,15 @@ import pw.binom.litert.LiteLlm
|
|||||||
*/
|
*/
|
||||||
internal class ChatAgent(
|
internal class ChatAgent(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
|
/**
|
||||||
|
* Публичное представление об агенте (имя + описание + чем полезен).
|
||||||
|
* Доступно через [ProtoAgent.info] — `:server` HTTP-фасад отдаёт его
|
||||||
|
* как `GET {path}`, A2A — использует для AgentCard. По умолчанию
|
||||||
|
* имя "agentik", описание/usefulness — отсутствуют (host может
|
||||||
|
* переопределить через standalone-конфиг `AGENTIK_NAME`,
|
||||||
|
* `AGENTIK_DESCRIPTION`, `AGENTIK_USEFULNESS`).
|
||||||
|
*/
|
||||||
|
override val info: AgentInfo = AgentInfo(name = "agentik"),
|
||||||
private val mutableConversationStore: MutableConversationStore,
|
private val mutableConversationStore: MutableConversationStore,
|
||||||
private val messageStore: MutableJournalStore,
|
private val messageStore: MutableJournalStore,
|
||||||
private val workingMemoryStore: ContextStore,
|
private val workingMemoryStore: ContextStore,
|
||||||
@@ -203,6 +215,13 @@ private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.In
|
|||||||
ttl = null,
|
ttl = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры).
|
||||||
|
* Онлайн-события никогда не сохраняются и не реплеятся — см. [OnlineOutbox].
|
||||||
|
* Durable-события по-прежнему идут в [eventStore].
|
||||||
|
*/
|
||||||
|
private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inmemory.InMemoryOnlineOutbox()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Собирает **актуальный** список тулов для диспетчеризации:
|
* Собирает **актуальный** список тулов для диспетчеризации:
|
||||||
* внешние из [toolProviders] + testTools + встроенные (read_skill,
|
* внешние из [toolProviders] + testTools + встроенные (read_skill,
|
||||||
@@ -361,10 +380,10 @@ private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.In
|
|||||||
for (entry in filtered.takeLast(limit * 2)) {
|
for (entry in filtered.takeLast(limit * 2)) {
|
||||||
val text = when (entry) {
|
val text = when (entry) {
|
||||||
is pw.binom.agentik.context.WorkingMemoryEntry.User ->
|
is pw.binom.agentik.context.WorkingMemoryEntry.User ->
|
||||||
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
|
entry.content.filterIsInstance<pw.binom.agentik.content.Content.Text>()
|
||||||
.joinToString("\n") { it.body }
|
.joinToString("\n") { it.body }
|
||||||
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant ->
|
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant ->
|
||||||
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
|
entry.content.filterIsInstance<pw.binom.agentik.content.Content.Text>()
|
||||||
.joinToString("\n") { it.body }
|
.joinToString("\n") { it.body }
|
||||||
else -> continue
|
else -> continue
|
||||||
}
|
}
|
||||||
@@ -403,6 +422,15 @@ private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.In
|
|||||||
override val outbox: OutboxStore
|
override val outbox: OutboxStore
|
||||||
get() = eventStore
|
get() = eventStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of [onlineEventStore] для HTTP-фасада в `:server`
|
||||||
|
* (`Route.agentikAgent` → `/conversations/{id}/online` SSE).
|
||||||
|
*
|
||||||
|
* Live-only: без catchup/курсора, события не сохраняются.
|
||||||
|
*/
|
||||||
|
override val onlineOutbox: OnlineOutbox
|
||||||
|
get() = onlineEventStore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only view of [mutableConversationStore] для HTTP-фасада в `:server`
|
* Read-only view of [mutableConversationStore] для HTTP-фасада в `:server`
|
||||||
* (`GET /conversations` → список [ConversationRecord] для UI).
|
* (`GET /conversations` → список [ConversationRecord] для UI).
|
||||||
@@ -446,6 +474,7 @@ private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.In
|
|||||||
workingMemoryStore = workingMemoryStore,
|
workingMemoryStore = workingMemoryStore,
|
||||||
reflectionStore = reflectionStore,
|
reflectionStore = reflectionStore,
|
||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
|
onlineEventStore = onlineEventStore,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
systemPromptResolver = { buildRuntimeSystemPrompt() },
|
systemPromptResolver = { buildRuntimeSystemPrompt() },
|
||||||
@@ -518,6 +547,7 @@ private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.In
|
|||||||
workingMemoryStore = workingMemoryStore,
|
workingMemoryStore = workingMemoryStore,
|
||||||
reflectionStore = reflectionStore,
|
reflectionStore = reflectionStore,
|
||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
|
onlineEventStore = onlineEventStore,
|
||||||
llm = llm,
|
llm = llm,
|
||||||
systemPrompt = systemPrompt,
|
systemPrompt = systemPrompt,
|
||||||
systemPromptResolver = { buildRuntimeSystemPrompt() },
|
systemPromptResolver = { buildRuntimeSystemPrompt() },
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ 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.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.context.WorkingMemoryRow
|
import pw.binom.agentik.context.WorkingMemoryRow
|
||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
|
|||||||
+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.journal.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
import pw.binom.agentik.journal.MessageOrigin
|
import pw.binom.agentik.content.MessageOrigin
|
||||||
|
|
||||||
internal class ContextBuilder(
|
internal class ContextBuilder(
|
||||||
private val memoryPrefetcher: MemoryPrefetcher?,
|
private val memoryPrefetcher: MemoryPrefetcher?,
|
||||||
|
|||||||
+15
@@ -5,10 +5,18 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.outbox.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фасад эмиссии и чтения событий одного диалога. Разводит два канала:
|
||||||
|
* - durable ([Event]) → [globalEventStore] (`:outbox`), с catchup по `after`;
|
||||||
|
* - online ([OnlineEvent]) → [onlineStore], live-only (без catchup).
|
||||||
|
*/
|
||||||
internal class ConversationEvents(
|
internal class ConversationEvents(
|
||||||
private val globalEventStore: MutableOutboxStore,
|
private val globalEventStore: MutableOutboxStore,
|
||||||
|
private val onlineStore: MutableOnlineOutbox,
|
||||||
private val conversationId: String,
|
private val conversationId: String,
|
||||||
) {
|
) {
|
||||||
fun tryEmit(event: Event): Boolean {
|
fun tryEmit(event: Event): Boolean {
|
||||||
@@ -27,4 +35,11 @@ internal class ConversationEvents(
|
|||||||
fun events(after: kotlin.time.Instant?): Flow<Event> =
|
fun events(after: kotlin.time.Instant?): Flow<Event> =
|
||||||
globalEventStore.conversationEvents(after = after, conversationId = conversationId)
|
globalEventStore.conversationEvents(after = after, conversationId = conversationId)
|
||||||
.map { it.event }
|
.map { it.event }
|
||||||
|
|
||||||
|
/** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */
|
||||||
|
fun tryEmitOnline(event: OnlineEvent): Boolean =
|
||||||
|
onlineStore.tryAppendOnline(conversationId, event)
|
||||||
|
|
||||||
|
/** Live-поток онлайн-событий диалога (без catchup — см. KDoc [pw.binom.agentik.outbox.OnlineOutbox]). */
|
||||||
|
fun onlineEvents(): Flow<OnlineEvent> = onlineStore.onlineEvents(conversationId)
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-18
@@ -19,20 +19,22 @@ import mu.KotlinLogging
|
|||||||
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.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.proto.Content as ProtoContent
|
import pw.binom.agentik.content.Content as ProtoContent
|
||||||
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||||
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||||
import pw.binom.agentik.reflection.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.proto.Message as ProtoMessage
|
import pw.binom.agentik.proto.Message as ProtoMessage
|
||||||
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
import pw.binom.agentik.content.MessageContext as ProtoMessageContext
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.journal.MutableConversationStore
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
import pw.binom.agentik.journal.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
import pw.binom.agentik.journal.MessageOrigin
|
import pw.binom.agentik.content.MessageOrigin
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore
|
import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore
|
||||||
import pw.binom.agentik.journal.TurnTokens
|
import pw.binom.agentik.content.TurnTokens
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||||
@@ -53,6 +55,11 @@ class ConversationLoop(
|
|||||||
private val workingMemoryStore: ContextStore,
|
private val workingMemoryStore: ContextStore,
|
||||||
private val reflectionStore: ReflectionStore?,
|
private val reflectionStore: ReflectionStore?,
|
||||||
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
|
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
|
||||||
|
/**
|
||||||
|
* Live-канал онлайн-событий (дельты ответа). Не сохраняется; подписка
|
||||||
|
* возможна только «онлайн». Durable-события по-прежнему в [eventStore].
|
||||||
|
*/
|
||||||
|
private val onlineEventStore: MutableOnlineOutbox,
|
||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
/**
|
/**
|
||||||
* Базовый системный промпт, который задаётся беседе при создании.
|
* Базовый системный промпт, который задаётся беседе при создании.
|
||||||
@@ -102,6 +109,7 @@ class ConversationLoop(
|
|||||||
|
|
||||||
private val events = ConversationEvents(
|
private val events = ConversationEvents(
|
||||||
globalEventStore = eventStore,
|
globalEventStore = eventStore,
|
||||||
|
onlineStore = onlineEventStore,
|
||||||
conversationId = state.id,
|
conversationId = state.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -170,6 +178,12 @@ class ConversationLoop(
|
|||||||
override suspend fun send(content: List<ProtoContent>, context: ProtoMessageContext?) {
|
override suspend fun send(content: List<ProtoContent>, context: ProtoMessageContext?) {
|
||||||
check(!state.isClosed) { "Conversation closed: $id" }
|
check(!state.isClosed) { "Conversation closed: $id" }
|
||||||
val turnStarted = now()
|
val turnStarted = now()
|
||||||
|
// Working-маркер — самый первый event хода. Эмитим синхронно через
|
||||||
|
// tryEmit (events.tryEmitOnline → без сетевого I/O), чтобы клиент
|
||||||
|
// увидел «агент работает» ещё до turnLock.withLock { launch } и до
|
||||||
|
// первого токена от LLM. Working/End — онлайн-маркеры (live-only),
|
||||||
|
// терминатор durable-части — AssistantMessage/Interrupted/Error.
|
||||||
|
emitOnline(OnlineEvent.Working(date = turnStarted))
|
||||||
val userMessageId = newId("msg")
|
val userMessageId = newId("msg")
|
||||||
val storageContext = context?.toStorage()
|
val storageContext = context?.toStorage()
|
||||||
|
|
||||||
@@ -192,6 +206,16 @@ class ConversationLoop(
|
|||||||
),
|
),
|
||||||
now = turnStarted,
|
now = turnStarted,
|
||||||
)
|
)
|
||||||
|
// Durable-событие user-сообщения: позволяет восстановить историю
|
||||||
|
// по курсору outbox без отдельного запроса в journal.
|
||||||
|
emitEvent(
|
||||||
|
ProtoEvent.UserMessage(
|
||||||
|
date = turnStarted,
|
||||||
|
id = userMessageId,
|
||||||
|
content = userRecord.content,
|
||||||
|
context = storageContext,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
turnLock.withLock {
|
turnLock.withLock {
|
||||||
@@ -253,8 +277,8 @@ class ConversationLoop(
|
|||||||
compactor.compactPreTurnIfNeeded()
|
compactor.compactPreTurnIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
emitEvent(ProtoEvent.StartReasoning(date = turnStarted))
|
emitOnline(OnlineEvent.StartReasoning(date = turnStarted))
|
||||||
emitEvent(ProtoEvent.StartResponse(date = now(), responseType = ProtoEvent.ResponseType.TEXT))
|
emitOnline(OnlineEvent.StartResponse(date = now(), responseType = OnlineEvent.ResponseType.TEXT))
|
||||||
|
|
||||||
val parts = userRecord.content.mapNotNull { c ->
|
val parts = userRecord.content.mapNotNull { c ->
|
||||||
when (c) {
|
when (c) {
|
||||||
@@ -321,7 +345,7 @@ class ConversationLoop(
|
|||||||
lc.sendStreamContents(pendingParts).collect { delta ->
|
lc.sendStreamContents(pendingParts).collect { delta ->
|
||||||
if (delta.text.isNotEmpty()) {
|
if (delta.text.isNotEmpty()) {
|
||||||
reply.append(delta.text)
|
reply.append(delta.text)
|
||||||
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
|
emitOnline(OnlineEvent.AppendText(date = now(), body = delta.text))
|
||||||
}
|
}
|
||||||
if (delta.toolCalls.isNotEmpty()) {
|
if (delta.toolCalls.isNotEmpty()) {
|
||||||
collectedCalls.addAll(delta.toolCalls)
|
collectedCalls.addAll(delta.toolCalls)
|
||||||
@@ -359,7 +383,7 @@ class ConversationLoop(
|
|||||||
}
|
}
|
||||||
if (delta.text.isNotEmpty()) {
|
if (delta.text.isNotEmpty()) {
|
||||||
reply.append(delta.text)
|
reply.append(delta.text)
|
||||||
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
|
emitOnline(OnlineEvent.AppendText(date = now(), body = delta.text))
|
||||||
}
|
}
|
||||||
if (delta.toolCalls.isNotEmpty()) {
|
if (delta.toolCalls.isNotEmpty()) {
|
||||||
nextCalls.addAll(delta.toolCalls)
|
nextCalls.addAll(delta.toolCalls)
|
||||||
@@ -376,7 +400,7 @@ class ConversationLoop(
|
|||||||
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
|
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
|
||||||
if (followUp.text.isNotEmpty()) {
|
if (followUp.text.isNotEmpty()) {
|
||||||
reply.append(followUp.text)
|
reply.append(followUp.text)
|
||||||
emitEvent(ProtoEvent.AppendText(date = now(), body = followUp.text))
|
emitOnline(OnlineEvent.AppendText(date = now(), body = followUp.text))
|
||||||
}
|
}
|
||||||
if (followUp.toolCalls.isNotEmpty()) {
|
if (followUp.toolCalls.isNotEmpty()) {
|
||||||
collectedPostTool.addAll(followUp.toolCalls)
|
collectedPostTool.addAll(followUp.toolCalls)
|
||||||
@@ -434,6 +458,17 @@ class ConversationLoop(
|
|||||||
)
|
)
|
||||||
messageStore.append(assistantRecord)
|
messageStore.append(assistantRecord)
|
||||||
|
|
||||||
|
// Durable-событие готового ответа агента.
|
||||||
|
emitEvent(
|
||||||
|
ProtoEvent.AssistantMessage(
|
||||||
|
date = assistantAt,
|
||||||
|
id = assistantId,
|
||||||
|
content = assistantContent,
|
||||||
|
reasoning = null,
|
||||||
|
tokens = turnTokens,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
workingMemoryStore.append(
|
workingMemoryStore.append(
|
||||||
conversationId = id,
|
conversationId = id,
|
||||||
entry = WorkingMemoryEntry.Assistant(
|
entry = WorkingMemoryEntry.Assistant(
|
||||||
@@ -475,7 +510,7 @@ class ConversationLoop(
|
|||||||
if (wasInterrupted || interrupted.get()) {
|
if (wasInterrupted || interrupted.get()) {
|
||||||
emitEvent(ProtoEvent.Interrupted(date = now()))
|
emitEvent(ProtoEvent.Interrupted(date = now()))
|
||||||
}
|
}
|
||||||
emitEvent(ProtoEvent.End(date = now()))
|
emitOnline(OnlineEvent.End(date = now()))
|
||||||
|
|
||||||
interrupted.set(false)
|
interrupted.set(false)
|
||||||
}
|
}
|
||||||
@@ -485,6 +520,10 @@ class ConversationLoop(
|
|||||||
events.tryEmit(event)
|
events.tryEmit(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun emitOnline(event: OnlineEvent) {
|
||||||
|
events.tryEmitOnline(event)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun failTurn(message: String, code: String? = null) {
|
private suspend fun failTurn(message: String, code: String? = null) {
|
||||||
val ts = now()
|
val ts = now()
|
||||||
if (!state.isTemporal) {
|
if (!state.isTemporal) {
|
||||||
@@ -523,9 +562,9 @@ internal fun ProtoContent.toStorage(): Content = when (this) {
|
|||||||
|
|
||||||
internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
|
internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
|
||||||
origin = when (origin) {
|
origin = when (origin) {
|
||||||
pw.binom.agentik.proto.MessageOrigin.USER -> MessageOrigin.USER
|
pw.binom.agentik.content.MessageOrigin.USER -> MessageOrigin.USER
|
||||||
pw.binom.agentik.proto.MessageOrigin.SYSTEM -> MessageOrigin.SYSTEM
|
pw.binom.agentik.content.MessageOrigin.SYSTEM -> MessageOrigin.SYSTEM
|
||||||
pw.binom.agentik.proto.MessageOrigin.EVENT -> MessageOrigin.EVENT
|
pw.binom.agentik.content.MessageOrigin.EVENT -> MessageOrigin.EVENT
|
||||||
},
|
},
|
||||||
description = description,
|
description = description,
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
@@ -534,9 +573,9 @@ internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
|
|||||||
|
|
||||||
internal fun MessageContext.toProto(): ProtoMessageContext {
|
internal fun MessageContext.toProto(): ProtoMessageContext {
|
||||||
val protoOrigin = when (origin) {
|
val protoOrigin = when (origin) {
|
||||||
MessageOrigin.USER -> pw.binom.agentik.proto.MessageOrigin.USER
|
MessageOrigin.USER -> pw.binom.agentik.content.MessageOrigin.USER
|
||||||
MessageOrigin.SYSTEM -> pw.binom.agentik.proto.MessageOrigin.SYSTEM
|
MessageOrigin.SYSTEM -> pw.binom.agentik.content.MessageOrigin.SYSTEM
|
||||||
MessageOrigin.EVENT -> pw.binom.agentik.proto.MessageOrigin.EVENT
|
MessageOrigin.EVENT -> pw.binom.agentik.content.MessageOrigin.EVENT
|
||||||
}
|
}
|
||||||
return ProtoMessageContext(
|
return ProtoMessageContext(
|
||||||
origin = protoOrigin,
|
origin = protoOrigin,
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.reflection.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import pw.binom.agentik.standalone.llm.LlmBackend
|
|||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
import pw.binom.agentik.mcp.bridge.McpConfig
|
import pw.binom.agentik.mcp.bridge.McpConfig
|
||||||
|
import pw.binom.agentik.proto.AgentInfo
|
||||||
|
|
||||||
// Inlined here (AppLimits.kt удалён параллельным рефакторингом): сетевые капы
|
// Inlined here (AppLimits.kt удалён параллельным рефакторингом): сетевые капы
|
||||||
// и env-parser limits живут рядом с тем, кто их использует, чтобы :config не
|
// и env-parser limits живут рядом с тем, кто их использует, чтобы :config не
|
||||||
@@ -62,7 +63,38 @@ data class AppConfig(
|
|||||||
* `null` — авторизация выключена. Источник: env `AGENTIK_A2A_TOKEN`.
|
* `null` — авторизация выключена. Источник: env `AGENTIK_A2A_TOKEN`.
|
||||||
*/
|
*/
|
||||||
val a2aToken: String? = null,
|
val a2aToken: String? = null,
|
||||||
)
|
/**
|
||||||
|
* Публичное имя ассистента — то, что видит пользователь/UI/A2A.
|
||||||
|
* Источник: env `AGENTIK_NAME`. Default — `"agentik"`.
|
||||||
|
*
|
||||||
|
* Контрактное отличие от [Agent.id] — id opaque-stable для корреляции,
|
||||||
|
* `name` — человекочитаемый и может меняться. В терминологии
|
||||||
|
* [pw.binom.agentik.proto.AgentInfo] это `info.name`.
|
||||||
|
*/
|
||||||
|
val name: String = DEFAULT_AGENT_NAME,
|
||||||
|
/**
|
||||||
|
* Краткое описание ассистента (что он делает / кто он).
|
||||||
|
* Источник: env `AGENTIK_DESCRIPTION`. `null` — секция не отдаётся
|
||||||
|
* клиенту (см. [pw.binom.agentik.proto.AgentInfo.description]).
|
||||||
|
*/
|
||||||
|
val description: String? = null,
|
||||||
|
/**
|
||||||
|
* Зачем ассистент может быть полезен (в каких задачах помогает).
|
||||||
|
* Источник: env `AGENTIK_USEFULNESS`. `null` — секция не отдаётся.
|
||||||
|
*/
|
||||||
|
val usefulness: String? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снапшот [pw.binom.agentik.proto.AgentInfo] из полей секции.
|
||||||
|
* Имя обязательно ([name] non-blank), остальные опциональные.
|
||||||
|
*/
|
||||||
|
fun toAgentInfo(): AgentInfo = AgentInfo(
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
usefulness = usefulness,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Долговременная память. */
|
/** Долговременная память. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -146,6 +178,7 @@ data class AppConfig(
|
|||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_PORT: Int = 8080
|
const val DEFAULT_PORT: Int = 8080
|
||||||
const val DEFAULT_DB_PATH: String = "./agentik.db"
|
const val DEFAULT_DB_PATH: String = "./agentik.db"
|
||||||
|
const val DEFAULT_AGENT_NAME: String = "agentik"
|
||||||
const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8
|
const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8
|
||||||
const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small"
|
const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small"
|
||||||
const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536
|
const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536
|
||||||
@@ -197,6 +230,9 @@ data class AppConfig(
|
|||||||
soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() },
|
soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() },
|
||||||
authToken = env("AGENTIK_TOKEN")?.takeIf { it.isNotBlank() },
|
authToken = env("AGENTIK_TOKEN")?.takeIf { it.isNotBlank() },
|
||||||
a2aToken = env("AGENTIK_A2A_TOKEN")?.takeIf { it.isNotBlank() },
|
a2aToken = env("AGENTIK_A2A_TOKEN")?.takeIf { it.isNotBlank() },
|
||||||
|
name = env("AGENTIK_NAME")?.takeIf { it.isNotBlank() } ?: DEFAULT_AGENT_NAME,
|
||||||
|
description = env("AGENTIK_DESCRIPTION")?.takeIf { it.isNotBlank() },
|
||||||
|
usefulness = env("AGENTIK_USEFULNESS")?.takeIf { it.isNotBlank() },
|
||||||
),
|
),
|
||||||
llm = llm,
|
llm = llm,
|
||||||
mcp = McpConfig.fromEnv(env),
|
mcp = McpConfig.fromEnv(env),
|
||||||
|
|||||||
+61
-10
@@ -8,7 +8,8 @@ import kotlinx.coroutines.flow.toList
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.outbox.AgentEvent
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.outbox.OnlineEvent
|
||||||
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||||
import pw.binom.agentik.skill.mining.SkillReadTool
|
import pw.binom.agentik.skill.mining.SkillReadTool
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
@@ -185,7 +186,7 @@ class ChatAgentTest {
|
|||||||
pw.binom.agentik.journal.MessageRecord.UserMessage(
|
pw.binom.agentik.journal.MessageRecord.UserMessage(
|
||||||
id = "m1",
|
id = "m1",
|
||||||
conversationId = id,
|
conversationId = id,
|
||||||
content = listOf(pw.binom.agentik.journal.Content.Text("hi")),
|
content = listOf(pw.binom.agentik.content.Content.Text("hi")),
|
||||||
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
|
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -213,10 +214,10 @@ class ChatAgentTest {
|
|||||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||||
assertEquals(2, msgs.size)
|
assertEquals(2, msgs.size)
|
||||||
assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let {
|
assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let {
|
||||||
(it[0] as pw.binom.agentik.journal.Content.Text).body
|
(it[0] as pw.binom.agentik.content.Content.Text).body
|
||||||
})
|
})
|
||||||
assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let {
|
assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let {
|
||||||
(it[0] as pw.binom.agentik.journal.Content.Text).body
|
(it[0] as pw.binom.agentik.content.Content.Text).body
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,12 +341,18 @@ class ChatAgentTest {
|
|||||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||||
}
|
}
|
||||||
|
val online = mutableListOf<OnlineEvent>()
|
||||||
|
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
|
||||||
|
}
|
||||||
conv.send(listOf(Content.Text("hi")))
|
conv.send(listOf(Content.Text("hi")))
|
||||||
delay(50)
|
delay(50)
|
||||||
job.cancel()
|
job.cancel()
|
||||||
|
onlineJob.cancel()
|
||||||
|
|
||||||
assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events")
|
assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events")
|
||||||
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
assertTrue(events.any { it is ProtoEvent.UserMessage }, "events=$events")
|
||||||
|
assertTrue(online.any { it is OnlineEvent.End }, "online=$online")
|
||||||
|
|
||||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||||
assertEquals(2, msgs.size)
|
assertEquals(2, msgs.size)
|
||||||
@@ -361,6 +368,45 @@ class ChatAgentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `message events are durable, Working-and-End and streaming go online`() = runTest {
|
||||||
|
// Durable-поток несёт целые сообщения (UserMessage/AssistantMessage).
|
||||||
|
// Working/End — маркеры фаз (онлайн, live-only), как и стриминг
|
||||||
|
// (StartReasoning / StartResponse / AppendText), который приходит
|
||||||
|
// из [OnlineOutbox], а НЕ из durable-потока.
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
val durable = mutableListOf<ProtoEvent>()
|
||||||
|
val online = mutableListOf<OnlineEvent>()
|
||||||
|
val durableJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { durable.add(it.event) }
|
||||||
|
}
|
||||||
|
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
|
||||||
|
}
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
delay(50)
|
||||||
|
durableJob.cancel()
|
||||||
|
onlineJob.cancel()
|
||||||
|
|
||||||
|
// durable: первое событие хода — user-сообщение; терминатор успешного
|
||||||
|
// хода — готовое assistant-сообщение.
|
||||||
|
assertTrue(durable.isNotEmpty(), "no events captured: $durable")
|
||||||
|
val first = durable.first()
|
||||||
|
assertIs<ProtoEvent.UserMessage>(first)
|
||||||
|
assertTrue(durable.any { it is ProtoEvent.AssistantMessage }, "no AssistantMessage: $durable")
|
||||||
|
// Working/End и стриминговые маркеры — онлайн-канал (live-only).
|
||||||
|
assertTrue(online.any { it is OnlineEvent.Working }, "no Working: $online")
|
||||||
|
assertTrue(online.any { it is OnlineEvent.StartReasoning }, "no StartReasoning: $online")
|
||||||
|
assertTrue(online.any { it is OnlineEvent.StartResponse }, "no StartResponse: $online")
|
||||||
|
assertTrue(online.any { it is OnlineEvent.End }, "no End: $online")
|
||||||
|
|
||||||
|
// Working не дублируется (emit'ится один раз на send()).
|
||||||
|
assertEquals(1, online.count { it is OnlineEvent.Working }, "Working emitted >1 times: $online")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest {
|
fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest {
|
||||||
// Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel()
|
// Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel()
|
||||||
@@ -377,6 +423,10 @@ class ChatAgentTest {
|
|||||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||||
}
|
}
|
||||||
|
val online = mutableListOf<OnlineEvent>()
|
||||||
|
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
val sendJob = launch {
|
val sendJob = launch {
|
||||||
try {
|
try {
|
||||||
@@ -390,6 +440,7 @@ class ChatAgentTest {
|
|||||||
conv.interrupt()
|
conv.interrupt()
|
||||||
sendJob.join()
|
sendJob.join()
|
||||||
eventsJob.cancel()
|
eventsJob.cancel()
|
||||||
|
onlineJob.cancel()
|
||||||
|
|
||||||
// audit: только user (assistant не успел сгенериться)
|
// audit: только user (assistant не успел сгенериться)
|
||||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||||
@@ -401,9 +452,9 @@ class ChatAgentTest {
|
|||||||
assertEquals(1, wm.size)
|
assertEquals(1, wm.size)
|
||||||
assertTrue(wm[0].entry is WorkingMemoryEntry.User)
|
assertTrue(wm[0].entry is WorkingMemoryEntry.User)
|
||||||
|
|
||||||
// events: должны включать Interrupted + End
|
// durable: включая Interrupted; End — онлайн-маркер.
|
||||||
assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events")
|
assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events")
|
||||||
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
assertTrue(online.any { it is OnlineEvent.End }, "online=$online")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -464,10 +515,10 @@ class ChatAgentTest {
|
|||||||
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
|
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
|
||||||
assertTrue(exchanges[0].resultText.contains("echo"))
|
assertTrue(exchanges[0].resultText.contains("echo"))
|
||||||
|
|
||||||
// events должны включать ToolCall + ToolResult. End — обязательно (turn завершился).
|
// events должны включать ToolCall + ToolResult. AssistantMessage — обязательно (turn завершился).
|
||||||
assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events")
|
assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events")
|
||||||
assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events")
|
assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events")
|
||||||
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
assertTrue(events.any { it is ProtoEvent.AssistantMessage }, "events=$events")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -575,7 +626,7 @@ class ChatAgentTest {
|
|||||||
val agent = newAgent(llm = toolLlm, tools = listOf(echoTool))
|
val agent = newAgent(llm = toolLlm, tools = listOf(echoTool))
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
conv.send(listOf(pw.binom.agentik.content.Content.Text("call the tool")))
|
||||||
|
|
||||||
// sendStreamContents вызывается дважды: первый раз с user-сообщением
|
// sendStreamContents вызывается дважды: первый раз с user-сообщением
|
||||||
// (LLM отвечает tool_call), второй раз — после addToolResult — для
|
// (LLM отвечает tool_call), второй раз — после addToolResult — для
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import pw.binom.agentik.standalone.llm.LlmBackend
|
|||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
import pw.binom.agentik.standalone.persistence.SqliteStores
|
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||||
import pw.binom.agentik.proto.Content as ProtoContent
|
import pw.binom.agentik.content.Content as ProtoContent
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
import pw.binom.litert.LiteConversationConfig
|
import pw.binom.litert.LiteConversationConfig
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
|
|||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
import pw.binom.agentik.journal.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
import pw.binom.agentik.journal.MessageOrigin.EVENT
|
import pw.binom.agentik.content.MessageOrigin.EVENT
|
||||||
import pw.binom.agentik.journal.MessageOrigin.SYSTEM
|
import pw.binom.agentik.content.MessageOrigin.SYSTEM
|
||||||
import pw.binom.agentik.journal.MessageOrigin.USER
|
import pw.binom.agentik.content.MessageOrigin.USER
|
||||||
import pw.binom.litert.LiteContentPart
|
import pw.binom.litert.LiteContentPart
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ import pw.binom.agentik.memory.MemorySystemGuidance
|
|||||||
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.memory.md.openMdMemorySystem
|
import pw.binom.agentik.memory.md.openMdMemorySystem
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
package pw.binom.agentik.standalone.persistence
|
package pw.binom.agentik.standalone.persistence
|
||||||
import pw.binom.agentik.journal.MessageContext
|
import pw.binom.agentik.content.MessageContext
|
||||||
import pw.binom.agentik.journal.MessageOrigin
|
import pw.binom.agentik.content.MessageOrigin
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.content.Content
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ plugins {
|
|||||||
// обычная SQLite БД. Подходит для small-to-medium масштабов (≤10K записей на
|
// обычная SQLite БД. Подходит для small-to-medium масштабов (≤10K записей на
|
||||||
// embedding ~512d); для больших датасетов — sqlite-vec или JVector.
|
// embedding ~512d); для больших датасетов — sqlite-vec или JVector.
|
||||||
//
|
//
|
||||||
// Цели сборки — только те, для которых ksqlite 0.1.2 опубликован в Maven Central:
|
// Цели сборки — только те, для которых ksqlite опубликован в Maven Central
|
||||||
// jvm + linuxX64/Arm64 + mingwX64. Apple/iOS/tvOS/watchOS — НЕ публикуются;
|
// (см. libs.versions.toml): jvm + linuxX64/Arm64 + mingwX64.
|
||||||
// для них использовать JVM-only `:vector-index-jvector`.
|
// Apple/iOS/tvOS/watchOS — НЕ публикуются; для них использовать
|
||||||
|
// JVM-only `:vector-index-jvector`.
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
@@ -20,8 +21,8 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// ksqlite 0.1.2 опубликован в Maven Central.
|
// ksqlite живёт в libs.versions.toml (см. catalog).
|
||||||
implementation("pw.binom.db:ksqlite:0.1.2")
|
implementation(libs.ksqlite)
|
||||||
api(project(":vector-index-api"))
|
api(project(":vector-index-api"))
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
|
|||||||
Reference in New Issue
Block a user