Изменение выносит типы контента в :content-api и разделяет потоки событий.
This commit is contained in:
+81
-37
@@ -72,9 +72,11 @@ dependencies {
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.time.Clock
|
||||
|
||||
@@ -87,21 +89,37 @@ fun main() = runBlocking {
|
||||
token = "s3cret", // или null, если не нужен
|
||||
)
|
||||
|
||||
// 2. Открыть диалог, отправить сообщение.
|
||||
val conv = agent.createConversation(temp = false)
|
||||
conv.send(listOf(Content.Text("Привет")))
|
||||
|
||||
// 3. Собирать streaming-ответ.
|
||||
conv.events(after = Clock.System.now()).collect { ev ->
|
||||
when (ev) {
|
||||
is Event.StartResponse -> println("[start]")
|
||||
is Event.AppendText -> print(ev.body)
|
||||
is Event.End -> println("[end]")
|
||||
is Event.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
// 2. Два независимых потока событий диалога:
|
||||
// durable (outbox) — целые события, с курсором после переподключения;
|
||||
// online (OnlineOutbox) — стриминг ответа, только live (без курсора).
|
||||
launch {
|
||||
agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id)
|
||||
.collect { ce ->
|
||||
when (val ev = ce.event) {
|
||||
is Event.AssistantMessage -> println("[answer ready: ${ev.content}]")
|
||||
is Event.Interrupted -> println("[interrupted]")
|
||||
is Event.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
|
||||
when (ev) {
|
||||
is OnlineEvent.Working -> println("[working]")
|
||||
is OnlineEvent.AppendText -> print(ev.body)
|
||||
is OnlineEvent.StartResponse -> println("[start]")
|
||||
is OnlineEvent.End -> println("\n[end]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Отправить ход (fire-and-forget — ответ придёт по подпискам выше).
|
||||
conv.send(listOf(Content.Text("Привет")))
|
||||
|
||||
// 4. Чистый shutdown.
|
||||
conv.close()
|
||||
agent.close()
|
||||
@@ -109,7 +127,14 @@ fun main() = runBlocking {
|
||||
```
|
||||
|
||||
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
|
||||
Ты только получаешь два типизированных `Flow` и рендеришь как хочешь.
|
||||
|
||||
> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их
|
||||
> можно перезапросить по курсору `after`. `OnlineEvent` (в
|
||||
> `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/
|
||||
> `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при
|
||||
> обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-
|
||||
> `Event.AssistantMessage` и/или ляжет в journal.
|
||||
|
||||
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
||||
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||
@@ -172,9 +197,11 @@ history.forEach { rec ->
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val agent = AgentikAgent(
|
||||
id = "agentik",
|
||||
@@ -183,16 +210,30 @@ val agent = AgentikAgent(
|
||||
)
|
||||
|
||||
val conv = agent.createConversation(temp = false)
|
||||
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||
|
||||
conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
||||
when (ev) {
|
||||
is Event.AppendText -> print(ev.body) // streaming чанки
|
||||
is Event.End -> println("\n--- end ---")
|
||||
is Event.Error -> error("agent error: ${ev.message}")
|
||||
else -> Unit
|
||||
// durable-поток (с курсором): terminal-события хода.
|
||||
launch {
|
||||
agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id)
|
||||
.collect { ce ->
|
||||
when (ce.event) {
|
||||
is Event.AssistantMessage -> println("\n--- answer ready ---")
|
||||
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
// online-поток (live-only): стриминг ответа.
|
||||
launch {
|
||||
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
|
||||
when (ev) {
|
||||
is OnlineEvent.AppendText -> print(ev.body) // streaming чанки
|
||||
is OnlineEvent.End -> println("\n--- end ---")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||
```
|
||||
|
||||
## История с локальным кэшем
|
||||
@@ -207,8 +248,8 @@ conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ChatSession(
|
||||
@@ -235,10 +276,11 @@ class ChatSession(
|
||||
after = Instant.DISTANT_PAST,
|
||||
).collect { cache.append(it) }
|
||||
}
|
||||
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
||||
// 2. Live: на каждом завершённом ходе (durable AssistantMessage)
|
||||
// просим у сервера новые записи.
|
||||
scope.launch {
|
||||
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||
if (ev is Event.End) {
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce ->
|
||||
if (ce.event is Event.AssistantMessage) {
|
||||
val newest = cache.let {
|
||||
// last-seen курсор — последний createdAt в кэше
|
||||
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||
@@ -342,22 +384,24 @@ escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступе
|
||||
появится в кэше через refresh-блок выше.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
|
||||
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||
agent.onlineOutbox.onlineEvents(convId).collect { ev ->
|
||||
when (ev) {
|
||||
is Event.StartResponse -> println("[start]")
|
||||
is Event.AppendText -> print(ev.body)
|
||||
is Event.AppendImage -> showImage(ev.body)
|
||||
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
|
||||
is Event.ToolResult -> println("[result]")
|
||||
is Event.End -> println("[end]")
|
||||
is Event.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
is OnlineEvent.Working -> println("[working]")
|
||||
is OnlineEvent.StartResponse -> println("[start]")
|
||||
is OnlineEvent.AppendText -> print(ev.body)
|
||||
is OnlineEvent.AppendImage -> showImage(ev.body)
|
||||
is OnlineEvent.End -> println("[end]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Инструментальные вызовы и целый ответ — durable-поток
|
||||
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и
|
||||
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
|
||||
|
||||
## Прерывание хода
|
||||
|
||||
```kotlin
|
||||
|
||||
@@ -10,10 +10,10 @@ import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,11 +13,16 @@ import pw.binom.agentik.outbox.OnlineOutbox
|
||||
* HTTP-реализация [OnlineOutbox] (= [pw.binom.agentik.outbox.OnlineOutbox]),
|
||||
* ходящая в `:server`-фасад.
|
||||
*
|
||||
* **Endpoint**: [onlineEvents] → `GET {baseUrl}/conversations/{id}/online`
|
||||
* (live-only SSE, без `after`). Сервер не реплеит — подписка получает
|
||||
* только то, что эмитится после подключения. Reconnect-логика здесь не
|
||||
* нужна: при обрыве поток просто закрывается, а потерянные дельты
|
||||
* восстанавливаются из durable-истории ([HttpEventStore] + журнал).
|
||||
* **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,
|
||||
@@ -26,8 +31,12 @@ internal class HttpOnlineOutbox(
|
||||
|
||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = flow {
|
||||
val url = "$agentUrl/conversations/$conversationId/online"
|
||||
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) {
|
||||
|
||||
@@ -66,7 +66,7 @@ private fun testEvent(dateMs: Long): CommonEvent =
|
||||
CommonEvent.Conversation(
|
||||
date = Instant.fromEpochMilliseconds(dateMs),
|
||||
conversationId = "test",
|
||||
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||
event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
Reference in New Issue
Block a user