Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e4c4d679f | |||
| 4c66947c25 | |||
| afdfb37e35 | |||
| 9e5d61707d | |||
| a3581abf84 |
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
*.iml
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
out/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
|
.cortexkit/
|
||||||
|
.veai/
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# IRC-транспорт — открытые вопросы
|
||||||
|
|
||||||
|
По мере закрытия отмечаем `- N. [x]`. Закрытый вопрос остаётся в файле с принятым решением.
|
||||||
|
|
||||||
|
- 1. [x] **История.** Принято: новый абстрактный метод `suspend fun getLatestMessages(offset: Int, limit: Int): List<Message>` в `:proto.Conversation` (offset = пропустить С КОНЦА, 0 = самые свежие). IRC-сервер не держит своего буфера, на `CHATHISTORY` дёргает агента. CAP `draft/chathistory` объявляем.
|
||||||
|
- 2. [x] **Tool/Error/Image события — раскладка по IRC.** Принято. Каждый `Event` мапится:
|
||||||
|
- `StartReasoning` → дропаем с провода
|
||||||
|
- `StartResponse(TEXT|IMAGE)` → CTCP `AGENTIK response-start {"type":"text"|"image"}`
|
||||||
|
- `AppendText(body)` → `PRIVMSG #chan :body`
|
||||||
|
- `AppendImage(body, mime)` → через `ImageStore` → CTCP `AGENTIK image {"url":..,"mime":..,"ttl":..}`
|
||||||
|
- `ToolCall` → CTCP `AGENTIK tool-call {json}`
|
||||||
|
- `ToolResult` → CTCP `AGENTIK tool-result {json}`
|
||||||
|
- `Error` → CTCP `AGENTIK error {json}`
|
||||||
|
- `End` → CTCP `AGENTIK end`
|
||||||
|
- `Interrupted` → CTCP `AGENTIK interrupted`
|
||||||
|
- 3. [x] **Interrupt.** **Упрощение:** команды `/stop` и `/interrupt` в `PRIVMSG` (т.е. `PRIVMSG #chan :/stop`) вызывают `Conversation.interrupt()`. Если `PRIVMSG` приходит во время активного размышления — сервер сначала зовёт `interrupt()`, затем `send(content)`. CTCP-вариант дропаем.
|
||||||
|
- 4. [x] **`AgentEvent.Created/Deleted/Renamed` маппинг.** Принято: Created = IRC `JOIN`-бродкаст; Deleted = `KICK` самого себя; Renamed = `TOPIC #foo :new title`.
|
||||||
|
- 5. [x] **NICK агента.** Принято: параметр в DSL, дефолт `"Agent"`.
|
||||||
|
- 6. [x] **Multi-user в канале.** Принято: **в канале всегда только наш агент и наш пользователь. Других не будет никогда.**
|
||||||
|
- 7. [x] **Маппинг канал ↔ Conversation.** Принято: имя IRC-канала = `Conversation.title`; `Conversation.id` = UUID, выдаётся через `CTCP AGENTIK id #foo`; при переименовании канала id стабилен.
|
||||||
|
- 8. [x] **Создание канала.** Принято: `JOIN #foo` → создаём `Conversation(title="foo", id=<uuid>)`. Если уже есть — заходим.
|
||||||
|
- 9. [x] **Удаление канала.** Принято: `PART` закрывает сторону клиента; `CTCP AGENTIK delete #foo` — удаление Conversation-а.
|
||||||
|
- 10. [x] **Модуль.** Принято: `:irc-server`, KMP через kotlinx-io. Также модуль содержит HTTP staging-эндпоинт для картинок (см. п.13).
|
||||||
|
- 11. [x] **Аутентификация клиента.** Принято: без auth, любой может подключиться.
|
||||||
|
- 12. [x] **Capabilities (ircv3).** Принято: в первом проходе объявляем `server-time`, `message-tags`, `batch`, `draft/chathistory`. SASL не объявляем (п.11). Остальные CAPs (echo-message, labeled-response, standard-replies, multi-user stuff) добавляем инкрементально.
|
||||||
|
- 13. [x] **ImageStore.** Принято: `ImageStore` живёт в `:irc-server`, дефолтная in-memory реализация с **TTL 600 сек**, staging-порт **авто-pick** (0 → свободный). Клиент через IRC картинки **не шлёт** (для этого HTTP `:server`). Конкретную реализацию `ImageStore` пользователь сделает позже сам, в первом проходе — наша in-memory.
|
||||||
|
|
||||||
|
## Все вопросы закрыты
|
||||||
|
|
||||||
|
Итого решений по `:irc-server`:
|
||||||
|
- Модуль `:irc-server`, KMP через kotlinx-io.
|
||||||
|
- Канал IRC = `Conversation` (имя = title, UUID через CTCP `AGENTIK id`).
|
||||||
|
- В канале всегда только 1 пользователь + агент (ник `Agent` по умолчанию).
|
||||||
|
- `Event` → IRC: `PRIVMSG` для текста, CTCP `AGENTIK <имя> {json}` для всего остального. `StartReasoning` дропается.
|
||||||
|
- Interrupt через `/stop` / `/interrupt` в PRIVMSG; входящий PRIVMSG во время размышления = `interrupt()` + `send()`.
|
||||||
|
- История через `CHATHISTORY` (LATEST/BEFORE/BETWEEN/AFTER), сервер не буферизует, дёргает новый `:proto` метод `getLatestMessages(offset, limit)`.
|
||||||
|
- Картинки только agent → client через `ImageStore` + HTTP staging в том же модуле.
|
||||||
|
- CAPs: `server-time`, `message-tags`, `batch`, `draft/chathistory`. Без auth.
|
||||||
|
|
||||||
|
Можно кодить.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "pw.binom.agentik"
|
||||||
|
version = "0.1.0"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.delete
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||||
|
*
|
||||||
|
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
|
||||||
|
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
|
||||||
|
* POST `/conversations`. Используем `runBlocking` — это одноразовая
|
||||||
|
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
|
||||||
|
* решает, что делать.
|
||||||
|
*/
|
||||||
|
internal class AgentClient(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
override val id: String,
|
||||||
|
) : Agent {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override fun createConversation(temp: Boolean): Conversation =
|
||||||
|
runBlocking {
|
||||||
|
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(RequestCreateConversation(temp))
|
||||||
|
}.body()
|
||||||
|
ConversationClient(httpClient = httpClient, baseUrl = agentUrl, snapshot = snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversation(id: String): Conversation? {
|
||||||
|
val response = httpClient.get("$agentUrl/conversations/$id")
|
||||||
|
if (response.status == HttpStatusCode.NotFound) return null
|
||||||
|
val snapshot = response.body<ConversationSnapshot>()
|
||||||
|
return ConversationClient(httpClient, agentUrl, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean {
|
||||||
|
val response = httpClient.delete("$agentUrl/conversations/$id")
|
||||||
|
return response.status == HttpStatusCode.NoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
|
||||||
|
val snapshots = httpClient.get("$agentUrl/conversations") {
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}.body<List<ConversationSnapshot>>()
|
||||||
|
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
||||||
|
val response = httpClient.get("$agentUrl/events?after=$after")
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"events: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
|
||||||
|
* (модуль `:server`).
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val client = AgentikAgent(
|
||||||
|
* id = "my-agent",
|
||||||
|
* baseUrl = "http://localhost:8080/agentik",
|
||||||
|
* )
|
||||||
|
* val conv = client.createConversation(temp = false)
|
||||||
|
* conv.send(listOf(Content.Text("hi")))
|
||||||
|
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
|
||||||
|
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
||||||
|
* конфига).
|
||||||
|
*
|
||||||
|
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
|
||||||
|
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
|
||||||
|
*/
|
||||||
|
fun AgentikAgent(
|
||||||
|
id: String,
|
||||||
|
baseUrl: String,
|
||||||
|
httpClient: HttpClient = defaultAgentikHttpClient(),
|
||||||
|
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
|
||||||
|
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
|
||||||
|
* (см. [readSse]) живёт в общем коде и плагина не требует.
|
||||||
|
*/
|
||||||
|
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.patch
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import pw.binom.agentik.proto.Message
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [Conversation]. Ходит в `:server`-фасад под
|
||||||
|
* `/conversations/{id}/...`.
|
||||||
|
*
|
||||||
|
* `id` отдаётся синхронно (он в [snapshot], доступном сразу). Остальные
|
||||||
|
* поля (`title`, `isSupportImageInput`, ...) — тоже из snapshot. [snapshot]
|
||||||
|
* обновляется после [rename] (сервер возвращает свежий).
|
||||||
|
*
|
||||||
|
* **Caveat — `updatedAt`:** сервер бампит `updatedAt` на каждый
|
||||||
|
* `send`/`rename`, но клиент узнает об этом только при следующем
|
||||||
|
* `rename` или `getConversation`. Если нужна свежая свежесть после
|
||||||
|
* `send` — перезапроси через `Agent.getConversation(id)`.
|
||||||
|
*
|
||||||
|
* [close] — локальный no-op: сервер держит диалог живым. Удалить —
|
||||||
|
* через `Agent.deleteConversation(id)`.
|
||||||
|
*/
|
||||||
|
internal class ConversationClient(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private var snapshot: ConversationSnapshot,
|
||||||
|
) : Conversation {
|
||||||
|
|
||||||
|
override val id: String get() = snapshot.id
|
||||||
|
override val title: String? get() = snapshot.title
|
||||||
|
override val isSupportImageInput: Boolean get() = snapshot.isSupportImageInput
|
||||||
|
override val isSupportImageOutput: Boolean get() = snapshot.isSupportImageOutput
|
||||||
|
override val isTemporal: Boolean get() = snapshot.isTemporal
|
||||||
|
override val updatedAt: Instant get() = snapshot.updatedAt
|
||||||
|
|
||||||
|
private val convUrl: String get() = "$baseUrl/conversations/$id"
|
||||||
|
|
||||||
|
override suspend fun rename(title: String) {
|
||||||
|
val updated = httpClient.patch(convUrl) {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(RequestRename(title))
|
||||||
|
}.body<ConversationSnapshot>()
|
||||||
|
snapshot = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(content: List<Content>) {
|
||||||
|
httpClient.post("$convUrl/messages") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() {
|
||||||
|
httpClient.post("$convUrl/interrupt")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<Event> = flow {
|
||||||
|
val response = httpClient.get("$convUrl/events?after=$after")
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"events: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||||
|
httpClient.get("$convUrl/messages") {
|
||||||
|
parameter("after", after.toString())
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}.body()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// Локальный no-op: диалог на сервере живёт, пока не вызван
|
||||||
|
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-снимок [pw.binom.agentik.proto.Conversation] — те же поля, что у
|
||||||
|
* интерфейса, но без методов. Зеркалит
|
||||||
|
* [pw.binom.agentik.server.ConversationSnapshot]. Дубликат сознательно:
|
||||||
|
* переедем в общий `:wire`, когда появится больше типов.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ConversationSnapshot(
|
||||||
|
val id: String,
|
||||||
|
val isSupportImageInput: Boolean,
|
||||||
|
val isSupportImageOutput: Boolean,
|
||||||
|
val isTemporal: Boolean,
|
||||||
|
val title: String? = null,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RequestCreateConversation(val temp: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RequestRename(val title: String)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Зеркалит [pw.binom.agentik.server.InstantSerializer]. Дублируем сознательно:
|
||||||
|
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
||||||
|
*/
|
||||||
|
internal object InstantSerializer : KSerializer<Instant> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: Instant) =
|
||||||
|
encoder.encodeString(value.toString())
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): Instant =
|
||||||
|
Instant.parse(decoder.decodeString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-конфиг клиента. Должен **точно** совпадать с серверным `agentikJson` —
|
||||||
|
* один и тот же wire-формат с обеих сторон.
|
||||||
|
*/
|
||||||
|
internal val agentikJson: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
serializersModule = SerializersModule {
|
||||||
|
contextual(Instant::class, InstantSerializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.utils.io.ByteReadChannel
|
||||||
|
import io.ktor.utils.io.readUTF8Line
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный парсер Server-Sent Events, читающий канал до EOF и эмиттящий
|
||||||
|
* собранный `data:`-пейлоад каждого события. Достаточно для нашего wire-формата:
|
||||||
|
* сервер шлёт `data: <json>\n\n`, имя события и прочие поля не используются.
|
||||||
|
*
|
||||||
|
* Формат (см. WHATWG):
|
||||||
|
* event: foo — игнор (у нас нет имён событий)
|
||||||
|
* data: {"k":1} — накапливается, многострочный `data:` склеивается через '\n'
|
||||||
|
* :comment — игнор
|
||||||
|
* id:/retry:/<blank> — пустая строка = граница события; всё остальное игнор
|
||||||
|
*
|
||||||
|
* Поток закрывается, когда канал доходит до EOF; накопленный `data` (если есть)
|
||||||
|
* эмитится как финальный ивент.
|
||||||
|
*/
|
||||||
|
internal fun readSse(channel: ByteReadChannel): Flow<String> = flow {
|
||||||
|
val data = StringBuilder()
|
||||||
|
while (!channel.isClosedForRead) {
|
||||||
|
val line = channel.readUTF8Line() ?: break
|
||||||
|
when {
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (data.isNotEmpty()) {
|
||||||
|
emit(data.toString())
|
||||||
|
data.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.startsWith("data: ") -> {
|
||||||
|
if (data.isNotEmpty()) data.append('\n')
|
||||||
|
data.append(line.removePrefix("data: "))
|
||||||
|
}
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
if (data.isNotEmpty()) data.append('\n')
|
||||||
|
data.append(line.removePrefix("data:"))
|
||||||
|
}
|
||||||
|
// event:, id:, retry:, ":" (comment) — игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.isNotEmpty()) emit(data.toString())
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Agentik — архитектура (на основе ограничений AGUI)
|
||||||
|
|
||||||
|
## 1. Контекст
|
||||||
|
|
||||||
|
agentik — runtime агента. Внешне он exposes два фасада:
|
||||||
|
- **AG-UI** — SSE-поток событий для UI-клиентов (`POST /agui`), транспорт Netty.
|
||||||
|
- **A2A** — JSON-RPC для agent↔agent (`POST /`, `message/send`), транспорт CIO.
|
||||||
|
|
||||||
|
Ядро (сессии, чат-цикл, тулы, LLM, MCP) **не зависит от транспорта**: фасады лишь сериализуют/десериализуют его. Библиотеки из каталога `caffeine`: `agui` 0.1.0, `a2a` 1.0.0-SNAPSHOT; LLM — `pw.binom.openai` (ktor-impl).
|
||||||
|
|
||||||
|
## 2. Ограничения AGUI (что диктует протокол)
|
||||||
|
|
||||||
|
AGUI — push-протокол. Агент = `Agent { RunAgentInput -> Flow<BaseEvent> }`.
|
||||||
|
|
||||||
|
Ключевые типы (из `pw.binom.agui.api`):
|
||||||
|
|
||||||
|
| Тип | Смысл |
|
||||||
|
|---|---|
|
||||||
|
| `RunAgentInput { threadId, runId, state, messages, tools, context, forwardedProps }` | весь ввод хода. Клиент шлёт всё, сервер по умолчанию stateless. |
|
||||||
|
| `threadId` | **сессия/конверсация**. |
|
||||||
|
| `runId` | **один ход** в рамках сессии. |
|
||||||
|
| `Message { id, role: developer\|system\|assistant\|user\|tool, content, toolCalls?, toolCallId? }` | сообщение. У assistant — `toolCalls`; у tool — `toolCallId`. |
|
||||||
|
| `Tool { name, description, parameters: JSONSchema }` | тул (JSON Schema в `parameters`). |
|
||||||
|
| `State = Map<String, JsonElement>` | нестрогий state хода (snapshot/delta). |
|
||||||
|
| `Context { description, value }` | кусок контекста. |
|
||||||
|
|
||||||
|
События (`BaseEvent`, дискриминатор `type`):
|
||||||
|
- lifecycle: `RUN_STARTED{threadId,runId}` → … → `RUN_FINISHED` / `RUN_ERROR{message,code}`
|
||||||
|
- steps: `STEP_STARTED/FINISHED{stepName}`
|
||||||
|
- text: `TEXT_MESSAGE_START{messageId,role}` → `TEXT_MESSAGE_CONTENT{messageId,delta}` → `TEXT_MESSAGE_END{messageId}`
|
||||||
|
- tool: `TOOL_CALL_START{toolCallId,toolCallName,parentMessageId?}` → `TOOL_CALL_ARGS{toolCallId,delta}` → `TOOL_CALL_END{toolCallId}` → `TOOL_CALL_RESULT{toolCallId,content}`
|
||||||
|
- state: `STATE_SNAPSHOT{snapshot}` / `STATE_DELTA{delta}` / `MESSAGES_SNAPSHOT{messages}`
|
||||||
|
- прочее: `RAW{event}` / `CUSTOM{name,value}`
|
||||||
|
|
||||||
|
Свободные варианты на стороне либы:
|
||||||
|
- stateless: `Agent { input -> flow }` (использует `input.messages/state/tools`).
|
||||||
|
- stateful: `AbstractAgent(agentId, threadId)` — держит `state` + `messages`, переопределяешь `runAgent(RunAgentParameters)`.
|
||||||
|
|
||||||
|
Сервер (`aguiAgent(agent, path="/agui")`) не хранит ничего между запросами: декодирует `RunAgentInput`, вызывает `agent.run`, стримит события, оборачивает сбой в `RUN_ERROR`.
|
||||||
|
|
||||||
|
## 3. Как вписываемся (принципы)
|
||||||
|
|
||||||
|
1. **`threadId` = наша сессия.** Держим `SessionStore[threadId]` на сервере даже при stateless-сервере AGUI: на каждом ходе реконсилируем `input.messages` с сохранёнными и дописываем новые. Это даёт «настоящие сессии» с историей.
|
||||||
|
2. **`runId` = ход.** Один `RunAgentInput` → один цикл ядра → один поток событий.
|
||||||
|
3. **Ядро говорит на AGUI-событиях** — это и есть наш протокол вывода; фасады не переопределяют его.
|
||||||
|
4. **LLM — за интерфейсом.** Ядро не знает, какая LLM. Точка входа `LlmClient.complete(...)`.
|
||||||
|
5. **Тулы: внутренний реестр + MCP.** `ToolRegistry` объединяет «родные» тулы и тулы MCP-серверов (через bridge). LLM вызывает их; ядро исполняет и возвращает `TOOL_CALL_RESULT`.
|
||||||
|
6. **А2A = request/response.** Переиспользуем то же ядро, но собираем финальный текст и отдаём одним A2A `Message`.
|
||||||
|
|
||||||
|
## 4. Пакеты / модули
|
||||||
|
|
||||||
|
```
|
||||||
|
pw.binom.agentik
|
||||||
|
├─ core
|
||||||
|
│ ├─ session Session(threadId, messages, state) + SessionStore (in-memory, by threadId)
|
||||||
|
│ ├─ tool ToolSpec, Tool, ToolRegistry (InMemoryToolRegistry, builtin tools)
|
||||||
|
│ ├─ llm LlmClient (interface) + LlmMessage/LlmToolCall/LlmResponse + EchoLlmClient (stub)
|
||||||
|
│ ├─ mcp McpClient (interface: listTools/callTool) + McpTool (bridge) + ToolRegistry.registerMcpClient
|
||||||
|
│ └─ engine AgentEngine: prompt -> LLM -> tool-loop -> AGUI events (RunStarted..RunFinished)
|
||||||
|
└─ fronts
|
||||||
|
├─ agui AgentikAgent : Agent { input -> engine.run(input) } [вместо EchoAgent]
|
||||||
|
└─ a2a EngineA2aHandler : A2A handle(msg, contextId) -> Message [собирает финальный текст]
|
||||||
|
```
|
||||||
|
Wiring (в `standalone`): `Main` собирает `AgentEngine(llm, tools+MCP, sessions)`, отдаёт его `AgentikAgent` (AGUI) и `EngineA2aHandler` (A2A). Конфигурация (LLM, MCP-серверы, тулы) — в одном месте (см. §8).
|
||||||
|
|
||||||
|
## 5. Поток данных (AGUI)
|
||||||
|
|
||||||
|
```
|
||||||
|
client ──POST /agui {RunAgentInput}──> AgentikAgent
|
||||||
|
│ engine.run(input)
|
||||||
|
▼
|
||||||
|
SessionStore.getOrCreate(threadId)
|
||||||
|
merge input.messages, input.tools
|
||||||
|
│
|
||||||
|
┌─> llm.complete(system, history, tools, state)
|
||||||
|
│ └─ toolCalls? -> TOOL_CALL_* + ToolRegistry.call + TOOL_CALL_RESULT -> llm.complete (loop)
|
||||||
|
└─> text? -> TEXT_MESSAGE_* (последнее)
|
||||||
|
│
|
||||||
|
persist final assistant msg in session
|
||||||
|
│
|
||||||
|
client <──SSE [BaseEvent]──────┘ (RunStarted .. RunFinished / RunError)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. A2A-фасад (request/response)
|
||||||
|
|
||||||
|
`A2A handle(request: Message, contextId): Message`:
|
||||||
|
1. `text = request.parts.filterIsInstance<TextPart>().join("")`.
|
||||||
|
2. `RunAgentInput(threadId = contextId ?: new, runId = randomRunId(), messages = [Message(USER, text)])`.
|
||||||
|
3. `events = engine.run(input)` (синхронно, `runBlocking { ...toList() }`).
|
||||||
|
4. `answer = events.filterIsInstance<TextMessageContentEvent>().join { it.delta }`.
|
||||||
|
5. вернуть `Message(role=AGENT, parts=[TextPart(answer)])`.
|
||||||
|
|
||||||
|
`contextId` ↔ `threadId` — общая сессия. A2A не стримит, поэтому события ядра сворачиваются в финальный ответ.
|
||||||
|
|
||||||
|
## 7. Зоны ответственности
|
||||||
|
|
||||||
|
**Ядро (скелет уже закладываем):**
|
||||||
|
`Session/SessionStore`, `Tool/ToolRegistry`, `LlmClient`(iface)+stub, `McpClient`(iface)+bridge, `AgentEngine`, `AgentikAgent`, `EngineA2aHandler`, `Main`.
|
||||||
|
|
||||||
|
**Ты (поверх ядра):**
|
||||||
|
- реализация `LlmClient` (через `pw.binom.openai:ktor-impl`),
|
||||||
|
- конкретные «родные» тулы,
|
||||||
|
- транспорт `McpClient` (подключение к MCP-серверам).
|
||||||
|
|
||||||
|
## 8. Открытые решения (нужны решения)
|
||||||
|
|
||||||
|
1. **Персист сессий** — in-memory или на диск/БД (сейчас in-memory).
|
||||||
|
2. **Конфигурация** — MCP-серверы, LLM endpoint/ключ, системный промпт: env / yaml / файл (сейчас env-заготовки: `AGENTIK_A2A_AGENTS`).
|
||||||
|
3. **Ограничения** — `maxToolRounds`, таймауты LLM/тулов, лимиты размера.
|
||||||
|
4. **Аутентификация** — Bearer на обоих фасадах (а2a/agui принимают `token`), пока не включена.
|
||||||
|
5. **Структура модулей** — держать всё в `standalone` (по пакетам) или вынести `core`/`mcp` в отдельные подмодули (для переиспользования).
|
||||||
|
6. **Поведение сессии** — «клиент источник правды» (replace) vs «сервер источник правды» (append) при реконсилии `messages`.
|
||||||
|
```
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# Standalone — рантайм агента agentik
|
||||||
|
|
||||||
|
`standalone` — это исполняемое JVM-приложение (точка входа `pw.binom.agentik.standalone.MainKt`), которое поднимает реальный агент `ChatAgent` (stateful, SQLite-персистентный) и навешивает на него HTTP+SSE фасад `:server`. LLM-движок выбирается через `AGENTIK_LLM_BACKEND` — на v1 поддерживаются `litert-openai` (любой OpenAI-совместимый endpoint) и `litert-google` (on-device движок LiteRT-LM 0.17.0 через нативную `.so`-библиотеку из litertlm-jvm 0.17.0).
|
||||||
|
|
||||||
|
Этот документ описывает, как `standalone` собран и как его расширять.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Что в коробке после `git clone`
|
||||||
|
|
||||||
|
```
|
||||||
|
:proto — единое ядро протокола (KMP, commonMain)
|
||||||
|
Agent / Conversation / Event / Message / Content / AgentEvent
|
||||||
|
stateful: агент сам хранит историю и working memory
|
||||||
|
|
||||||
|
:server — HTTP+SSE фасад :proto
|
||||||
|
public Route.agentikAgent(agent, path = "/agentik")
|
||||||
|
|
||||||
|
:standalone — JVM-рантайм с реальным LLM-агентом
|
||||||
|
ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google)
|
||||||
|
default 8080:
|
||||||
|
GET /health health check
|
||||||
|
POST /agentik/conversations создать диалог (201)
|
||||||
|
GET /agentik/conversations список
|
||||||
|
GET /agentik/conversations/{id} один диалог
|
||||||
|
PATCH /agentik/conversations/{id} переименовать
|
||||||
|
DELETE /agentik/conversations/{id} удалить (204)
|
||||||
|
POST /agentik/conversations/{id}/messages отправить user-сообщение (202)
|
||||||
|
POST /agentik/conversations/{id}/interrupt прервать текущий ход (202)
|
||||||
|
GET /agentik/conversations/{id}/messages страница истории
|
||||||
|
GET /agentik/conversations/{id}/events SSE live-события хода
|
||||||
|
GET /agentik/events SSE live-события агента
|
||||||
|
```
|
||||||
|
|
||||||
|
Полная таблица эндпоинтов — в `docs/ARCHITECTURE.md` (раздел «:server»).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Архитектура слоёв
|
||||||
|
|
||||||
|
```
|
||||||
|
клиенты транспорт
|
||||||
|
┌───────────────┐ ┌─────────────────────────────┐
|
||||||
|
│ Web / CLI / │ ──HTTP──► │ Route.agentikAgent(agent) │
|
||||||
|
│ desktop │ ──SSE───► │ :server (Ktor + Netty) │
|
||||||
|
│ │ └──────────────┬──────────────┘
|
||||||
|
└───────────────┘ │
|
||||||
|
▼
|
||||||
|
pw.binom.agentik.proto.Agent
|
||||||
|
(ChatAgent)
|
||||||
|
│
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
▼ ▼
|
||||||
|
ChatConversation.send(content) agent.events / agent.getConversations
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 1. audit: append UserMessage │
|
||||||
|
│ 2. working_memory: append User │
|
||||||
|
│ 3. ensureLiteConversation: │
|
||||||
|
│ first turn → create from WM; │
|
||||||
|
│ next turns → reuse (KV-cache) │
|
||||||
|
│ 4. sendStreamContents → emit │
|
||||||
|
│ StartResponse / AppendText / │
|
||||||
|
│ End │
|
||||||
|
│ 5. audit + WM: append AssistantMessage│
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Conversation.events(after) Conversation.getMessages(after)
|
||||||
|
(live, no replay) (история)
|
||||||
|
```
|
||||||
|
|
||||||
|
Слои рантайма:
|
||||||
|
|
||||||
|
```
|
||||||
|
:standalone
|
||||||
|
├── persistence/ ← интерфейсы и records (commonMain, без зависимостей)
|
||||||
|
│ ConversationStore / MessageStore / WorkingMemoryStore
|
||||||
|
│ ConversationRecord / MessageRecord / WorkingMemoryEntry / Content
|
||||||
|
│ Payload.kt — JSON-сериализация
|
||||||
|
├── persistence/sqlite/ ← JVM: SQLDelight-схема + три SQLite-реализации
|
||||||
|
│ SqliteStores.open(path | inMemory)
|
||||||
|
│ src/jvmMain/sqldelight/.../*.sq
|
||||||
|
├── llm/ ← LlmConfig (env → OpenAI/Google), backend-agnostic
|
||||||
|
└── agent/ ← ChatAgent + ChatConversation (stateful, long-lived LiteConv)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевой инвариант: **разговор живёт внутри агента, а не в клиенте и не в транспорте.** Транспорт — лишь сериализатор: HTTP пишет в/читает из `:server`-эндпоинтов, SSE шлёт события. У них нет своего состояния диалога.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Точка входа: `pw.binom.agentik.standalone.MainKt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080
|
||||||
|
val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db"
|
||||||
|
|
||||||
|
val llmConfig = LlmConfig.fromEnv()
|
||||||
|
val llm = llmConfig.createLlm()
|
||||||
|
val stores = SqliteStores.open(dbPath = dbPath)
|
||||||
|
val agent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = llm,
|
||||||
|
llmConfig = llmConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
val server = embeddedServer(Netty, port = port) {
|
||||||
|
routing {
|
||||||
|
get("/health") { call.respondText("ok") }
|
||||||
|
agentikAgent(agent, path = "/agentik")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
|
agent.close(); stores.close(); llm.close()
|
||||||
|
})
|
||||||
|
server.start(wait = true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Один `ChatAgent` отвечает и за диалоги (`/agentik/conversations/...`), и за live-события (`/agentik/events`). Все три ресурса — БД, LLM, Netty — корректно закрываются в shutdown-хуке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Контракт `Agent` (от `pw.binom.agentik.proto`)
|
||||||
|
|
||||||
|
Реализация **обязана** уметь:
|
||||||
|
|
||||||
|
| метод | смысл |
|
||||||
|
|---|---|
|
||||||
|
| `id: String` | идентификатор агента |
|
||||||
|
| `createConversation(temp: Boolean): Conversation` | новая сессия, `temp=true` — не персистить |
|
||||||
|
| `getConversation(id): Conversation?` | достать по id, `null` если нет |
|
||||||
|
| `deleteConversation(id): Boolean` | удалить |
|
||||||
|
| `getConversations(offset, limit)` | страница списка |
|
||||||
|
| `events(after: Instant): Flow<AgentEvent>` | live-события по множеству разговоров |
|
||||||
|
|
||||||
|
Реализация `Conversation`:
|
||||||
|
|
||||||
|
| метод | смысл |
|
||||||
|
|---|---|
|
||||||
|
| `id: String` | идентификатор диалога |
|
||||||
|
| `title: String?` | заголовок (может быть `null`) |
|
||||||
|
| `isTemporal: Boolean` | `true` = не персистить (`temp=true` при создании) |
|
||||||
|
| `isSupportImageInput/Output: Boolean` | мультимодальные возможности (для v1 оба `false`) |
|
||||||
|
| `updatedAt: Instant` | последний `send`/`rename` |
|
||||||
|
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
|
||||||
|
| `interrupt()` | остановить текущий ход (best-effort) |
|
||||||
|
| `events(after): Flow<Event>` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) |
|
||||||
|
| `getMessages(after, offset, limit)` | страница истории |
|
||||||
|
| `rename(title)` | переименовать |
|
||||||
|
| `close()` | освободить ресурсы |
|
||||||
|
|
||||||
|
Главное: `send` ничего не возвращает. Чтобы получить события, нужно **отдельно** подписаться на `events(after)` ДО `send` либо сразу после — поток событий стартует с момента подписки, бэкфилл через `getMessages`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Persistence — dual-log
|
||||||
|
|
||||||
|
`ChatAgent` хранит каждую сессию в двух логически разных таблицах:
|
||||||
|
|
||||||
|
| таблица | назначение | мутации |
|
||||||
|
|---|---|---|
|
||||||
|
| `message` | append-only audit log. Все user/assistant/tool-call/tool-result сообщения. Никогда не редактируется (кроме каскадного `DELETE` при удалении диалога). | только `INSERT` |
|
||||||
|
| `working_memory` | mutable LLM-контекст. System-prompt + текущая история + (в v2) суммаризации. | `INSERT`, `compact(dropFromIdx, summary)` |
|
||||||
|
|
||||||
|
Маппинг `:proto.Message ↔ MessageRecord` живёт в `ChatConversation.kt` (`toProto`/`toStorage`) — сами `MessageRecord` намеренно НЕ зависят от `:proto`, чтобы можно было сменить транспорт без миграции таблиц.
|
||||||
|
|
||||||
|
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
|
||||||
|
|
||||||
|
### `MessageStore`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun append(record: MessageRecord)
|
||||||
|
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||||
|
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WorkingMemoryStore`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
|
suspend fun list(conversationId: String): List<WorkingMemoryRow>
|
||||||
|
suspend fun clear(conversationId: String)
|
||||||
|
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
|
||||||
|
```
|
||||||
|
|
||||||
|
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
|
||||||
|
|
||||||
|
### `ConversationStore`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun upsert(record: ConversationRecord)
|
||||||
|
suspend fun get(id: String): ConversationRecord?
|
||||||
|
suspend fun delete(id: String): Boolean // каскадно чистит message + working_memory
|
||||||
|
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
|
||||||
|
suspend fun rename(id: String, title: String?): Instant?
|
||||||
|
suspend fun touch(id: String, now: Instant)
|
||||||
|
```
|
||||||
|
|
||||||
|
Все три store — `AutoCloseable`; корневой ресурс `SqliteStores` закрывает их вместе с `SqlDriver`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LLM-конфигурация (`LlmConfig`)
|
||||||
|
|
||||||
|
`LlmConfig.fromEnv()` парсит env, валидирует обязательные поля и выбирает бэкенд через `AGENTIK_LLM_BACKEND`:
|
||||||
|
|
||||||
|
- `openai` (default) — `litert-openai`, текст-онли чат против любого OpenAI-совместимого endpoint.
|
||||||
|
- `google` — `litert-google` (LiteRT-LM 0.17.0, on-device `.task`/`.litertlm` модель, требует нативной библиотеки через `litertlm-jvm`).
|
||||||
|
|
||||||
|
### Общие env
|
||||||
|
|
||||||
|
| env | смысл | default |
|
||||||
|
|---|---|---|
|
||||||
|
| `AGENTIK_PORT` | порт Netty (`/agentik`, `/health`) | `8080` |
|
||||||
|
| `AGENTIK_DB_PATH` | путь к SQLite-файлу | `./agentik.db` |
|
||||||
|
| `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` |
|
||||||
|
| `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» |
|
||||||
|
| `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) |
|
||||||
|
|
||||||
|
### Backend `openai`
|
||||||
|
|
||||||
|
| env | смысл |
|
||||||
|
|---|---|
|
||||||
|
| `OPENAI_BASE_URL` | endpoint (например, `https://api.openai.com/v1` или `http://localhost:11434/v1`) — обязательно |
|
||||||
|
| `OPENAI_API_KEY` | ключ модели — обязательно |
|
||||||
|
| `OPENAI_MODEL` | имя модели (например, `gpt-4o-mini`, `myopenai/local/codding`) — обязательно |
|
||||||
|
|
||||||
|
### Backend `google` (on-device LiteRT-LM)
|
||||||
|
|
||||||
|
| env | смысл | default |
|
||||||
|
|---|---|---|
|
||||||
|
| `AGENTIK_GOOGLE_MODEL_PATH` | путь к `.task` или `.litertlm` модели — обязательно |
|
||||||
|
| `AGENTIK_GOOGLE_CACHE_DIR` | каталог кеша скомпилированных graph'ов | пусто (системный tmp) |
|
||||||
|
| `AGENTIK_GOOGLE_THREADS` | число CPU-потоков для движка | `4` |
|
||||||
|
|
||||||
|
`AGENTIK_DB_PATH=:memory:` создаёт in-memory БД (только для тестов и интеграционных проверок).
|
||||||
|
|
||||||
|
### Long-lived LiteConversation — ОБЯЗАТЕЛЬНО для обоих бэкендов
|
||||||
|
|
||||||
|
`LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (LiteRT-LM 0.17.0 умеет правильно восстанавливать state при `systemInstruction + пары User/Assistant` в `initialMessages`).
|
||||||
|
|
||||||
|
`LiteLlm.capabilities: LiteCapabilities?` (litert-api 7+) — `litert-google` читает из заголовка on-disk модели (text/vision/audio, supportsThinking, supportsFunctionCalling, maxVisionTokenBudget); `litert-openai` возвращает `null` (модель не хранится на диске). Используй для фильтрации модальностей в клиенте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Расширение
|
||||||
|
|
||||||
|
### Подключить тулы (MCP / in-agent)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 1) In-agent tool (нативный LiteTool):
|
||||||
|
val echoTool = object : LiteTool {
|
||||||
|
override fun describe(): String =
|
||||||
|
"""{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}"""
|
||||||
|
override fun invoke(args: String): String = "echoed: $args"
|
||||||
|
}
|
||||||
|
val tools = listOf(NamedTool("echo", echoTool))
|
||||||
|
|
||||||
|
// 2) MCP (stdio / streamable HTTP) — конфиг в Claude Desktop-формате:
|
||||||
|
val mcp = McpConfig.fromEnv() // читает AGENTIK_MCP_CONFIG=path/to/mcp.json
|
||||||
|
val registry = McpRegistry.fromConfig(mcp) // стартует все серверы, лист LiteTool'ов
|
||||||
|
val tools = registry.namedTools // server__tool префикс автоматически
|
||||||
|
|
||||||
|
// 3) В обоих случаях:
|
||||||
|
val agent = ChatAgent(id, stores, llm, llmConfig, tools = tools)
|
||||||
|
```
|
||||||
|
|
||||||
|
`LiteConversation` принимает `tools = ...` в `LiteConversationConfig`. На каждый `delta.toolCalls` из модели `ChatConversation.runTurn`:
|
||||||
|
|
||||||
|
1. Эмитит `Event.ToolCall(callId, toolName, argsJson)` клиенту (по SSE)
|
||||||
|
2. Записывает `MessageRecord.ToolCall` в audit + working memory (если не temp)
|
||||||
|
3. Вызывает `tool.invoke(argsJson)`
|
||||||
|
4. Эмитит `Event.ToolResult(resultId, resultText)`
|
||||||
|
5. Записывает `MessageRecord.ToolResult` (с `toolCallId = callId`)
|
||||||
|
6. Кормит `liteConv.addToolResult(callId, name, result)` в LiteConversation (KV-cache выживает между итерациями)
|
||||||
|
7. Цикл повторяется до `delta.toolCalls.isEmpty()`
|
||||||
|
|
||||||
|
ID у `ToolCall` и `ToolResult` разные (`tc-…` / `tr-…`), но `MessageRecord.ToolResult.toolCallId` указывает на `MessageRecord.ToolCall.id` той же логической пары. Этим достигается уникальность PK в таблице `message`.
|
||||||
|
|
||||||
|
### Добавить ещё один транспорт
|
||||||
|
|
||||||
|
Каждый транспорт — отдельный модуль, который получает `Agent` и сериализует его под свой протокол:
|
||||||
|
|
||||||
|
* `:server` (HTTP+SSE) — готов, `Route.agentikAgent(agent, path = "/agentik")`
|
||||||
|
* `:client` (HTTP-клиент) — готов, `AgentikAgent(id, baseUrl, httpClient)`
|
||||||
|
* `:irc-server` — IRC-фасад, в планах
|
||||||
|
|
||||||
|
Транспорт **не имеет доступа к внутренностям `ChatAgent`** — он видит только интерфейс `Agent`. Это и есть «транспортно-агностичное ядро».
|
||||||
|
|
||||||
|
### Добавить ещё один LLM-бэкенд
|
||||||
|
|
||||||
|
1. Описать `Config` data class с нужными полями.
|
||||||
|
2. Реализовать `LiteLlm`/`LiteConversation` поверх движка (см. litert-kmp — там уже есть `litert-google`, `litert-openai`, `litert-koog`).
|
||||||
|
3. Расширить `LlmConfig.createLlm()` веткой `when`.
|
||||||
|
|
||||||
|
### Заменить SQLite на Postgres / MongoDB / etc
|
||||||
|
|
||||||
|
1. Реализовать три store-интерфейса поверх нового движка.
|
||||||
|
2. Передать их в `ChatAgent` вместо `SqliteStores`.
|
||||||
|
3. Удалить (или оставить за `:standalone`-флагом) `:persistence/sqlite/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Что НЕ делает `standalone` сегодня
|
||||||
|
|
||||||
|
* **Нет суммаризации.** `WorkingMemoryStore.compact` уже есть, но без LLM-вызова для генерации текста суммаризации.
|
||||||
|
* **Нет авторизации.** Все эндпоинты открыты.
|
||||||
|
* **Нет инкрементальной догрузки старых сообщений.** `getMessages(after)` работает с offset/limit, но без «схлопывания» (compaction в визуальной истории — задача клиента).
|
||||||
|
|
||||||
|
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[versions]
|
||||||
|
kotlin = "2.4.20"
|
||||||
|
kotlinx-serialization = "1.11.0"
|
||||||
|
kotlinx-coroutines = "1.11.0"
|
||||||
|
kotlinx-datetime = "0.8.0"
|
||||||
|
ktor = "3.1.3"
|
||||||
|
agui = "0.1.0"
|
||||||
|
a2a = "1.0.0-SNAPSHOT"
|
||||||
|
litert = "7"
|
||||||
|
sqldelight = "2.3.2"
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
# --- AG-UI (pw.binom.agui) — KMP: jvm + native ---
|
||||||
|
agui-api = { module = "pw.binom.agui:api", version.ref = "agui" }
|
||||||
|
agui-client = { module = "pw.binom.agui:client", version.ref = "agui" }
|
||||||
|
agui-server = { module = "pw.binom.agui:server", version.ref = "agui" }
|
||||||
|
|
||||||
|
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
|
||||||
|
a2a-shared = { module = "pw.binom.a2a:shared", version.ref = "a2a" }
|
||||||
|
a2a-client = { module = "pw.binom.a2a:client", version.ref = "a2a" }
|
||||||
|
a2a-server = { module = "pw.binom.a2a:server", version.ref = "a2a" }
|
||||||
|
|
||||||
|
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
|
||||||
|
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
||||||
|
litert-openai = { module = "pw.binom.litert:litert-openai-jvm", version.ref = "litert" }
|
||||||
|
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
|
||||||
|
|
||||||
|
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
||||||
|
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||||
|
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||||
|
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||||
|
|
||||||
|
# --- Ktor (сервер, JVM) ---
|
||||||
|
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||||
|
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
|
||||||
|
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
|
||||||
|
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||||
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
|
||||||
|
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
||||||
|
|
||||||
|
# --- Model Context Protocol (MCP) ---
|
||||||
|
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
||||||
|
|
||||||
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
# --- commons ---
|
||||||
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+92
@@ -0,0 +1,92 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор AG-UI api.
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.datetime)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ядро собственного протокола agentik (замена AG-UI). Явно stateful.
|
||||||
|
*
|
||||||
|
* Транспортно-агностично. [Agent] — фабрика stateful-диалогов:
|
||||||
|
* [createConversation] возвращает [Conversation], который сам хранит историю
|
||||||
|
* и которому отправляют ходы через [Conversation.send].
|
||||||
|
*/
|
||||||
|
public interface Agent {
|
||||||
|
|
||||||
|
/** Идентификатор агента. */
|
||||||
|
val id: String
|
||||||
|
|
||||||
|
/** Создаёт новый stateful-диалог с агентом. */
|
||||||
|
fun createConversation(temp: Boolean): Conversation
|
||||||
|
|
||||||
|
/** Диалог по идентификатору; `null`, если не найден. */
|
||||||
|
suspend fun getConversation(id: String): Conversation?
|
||||||
|
|
||||||
|
/** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */
|
||||||
|
suspend fun deleteConversation(id: String): Boolean
|
||||||
|
|
||||||
|
/** Страница диалогов: не более [limit] штук, начиная с [offset]-го. */
|
||||||
|
suspend fun getConversations(offset: Int, limit: Int): List<Conversation>
|
||||||
|
|
||||||
|
/** Все диалоги, начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */
|
||||||
|
fun getConversations(offset: Int = 0): Flow<Conversation> = flow {
|
||||||
|
var skip = offset
|
||||||
|
while (true) {
|
||||||
|
val page = getConversations(skip, PAGE_SIZE)
|
||||||
|
if (page.isEmpty()) break
|
||||||
|
page.forEach { emit(it) }
|
||||||
|
skip += page.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-подписка на изменения в множестве диалогов агента: создание,
|
||||||
|
* удаление, переименование (см. [AgentEvent]). События внутри конкретного
|
||||||
|
* диалога приходят через [Conversation.events].
|
||||||
|
*
|
||||||
|
* **Не реплеит** прошлое — для снимка множества используй [getConversations]
|
||||||
|
* или [getConversation].
|
||||||
|
*/
|
||||||
|
fun events(after: Instant): Flow<AgentEvent>
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val PAGE_SIZE: Int = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-события уровня [Agent]: изменения в множестве диалогов
|
||||||
|
* (создание, удаление, переименование). События, происходящие **внутри**
|
||||||
|
* конкретного диалога, приходят через [Conversation.events], а не сюда.
|
||||||
|
*
|
||||||
|
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
|
||||||
|
* идентична [Conversation.events]: поток **не реплеит** прошлое, для бэкфилла
|
||||||
|
* используются `getConversations`/`getConversation`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface AgentEvent {
|
||||||
|
/** Момент эмиссии события в UTC. */
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создан новый диалог. Передаётся его id — handle можно получить через
|
||||||
|
* [Agent.getConversation]. Подписчик после [Created] может сразу открыть
|
||||||
|
* live-подписку на этот диалог через [Conversation.events].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("created")
|
||||||
|
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диалог удалён. Переданный [Conversation]-handle реализация обязана
|
||||||
|
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
|
||||||
|
* пользоваться handle нельзя.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("deleted")
|
||||||
|
data class Deleted(override val date: Instant, val id: String) : AgentEvent
|
||||||
|
|
||||||
|
/** У диалога сменился заголовок. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("renamed")
|
||||||
|
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful-диалог клиента и [Agent]. Хранит собственную историю: на каждый
|
||||||
|
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
|
||||||
|
* [Conversation].
|
||||||
|
*/
|
||||||
|
interface Conversation : AutoCloseable {
|
||||||
|
val id: String
|
||||||
|
|
||||||
|
val isSupportImageInput: Boolean
|
||||||
|
val isSupportImageOutput: Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Признак временного диалога: не персистится между перезапусками агента,
|
||||||
|
* живёт только в памяти текущего процесса.
|
||||||
|
*/
|
||||||
|
val isTemporal: Boolean
|
||||||
|
|
||||||
|
val title: String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Момент последнего изменения диалога (любой [send], [rename] и т.п.) в UTC.
|
||||||
|
* Используется для сортировки списка диалогов по свежести.
|
||||||
|
*/
|
||||||
|
val updatedAt: Instant
|
||||||
|
|
||||||
|
suspend fun rename(title: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ставит новый user-ход в очередь. Возвращает управление сразу — поток
|
||||||
|
* событий ответа приходит через [events].
|
||||||
|
*
|
||||||
|
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
|
||||||
|
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
|
||||||
|
*/
|
||||||
|
suspend fun send(content: List<Content>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
|
||||||
|
* in-flight tool может доехать или отвалиться). В [events] эмитится
|
||||||
|
* [Event.Interrupted], затем может начаться следующий ход из очереди.
|
||||||
|
*
|
||||||
|
* Если хода нет — no-op.
|
||||||
|
*/
|
||||||
|
suspend fun interrupt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-подписка на всё, что происходит в диалоге, начиная с [after].
|
||||||
|
*
|
||||||
|
* **Не реплеит** события, произошедшие до [after] — для бэкфилла
|
||||||
|
* используй [getMessages]. Если [after] — момент последнего виденного
|
||||||
|
* клиентом события, поток продолжается «с того места».
|
||||||
|
*
|
||||||
|
* Подписки независимы: каждый вызов возвращает свой [Flow], отмена одного
|
||||||
|
* не влияет на других подписчиков и на сам диалог.
|
||||||
|
*/
|
||||||
|
fun events(after: Instant): Flow<Event>
|
||||||
|
|
||||||
|
/** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */
|
||||||
|
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
|
||||||
|
|
||||||
|
/** Все сообщения после [after] начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */
|
||||||
|
fun getMessages(after: Instant, offset: Int = 0): Flow<Message> = flow {
|
||||||
|
var skip = offset
|
||||||
|
while (true) {
|
||||||
|
val page = getMessages(after, skip, PAGE_SIZE)
|
||||||
|
if (page.isEmpty()) break
|
||||||
|
page.forEach { emit(it) }
|
||||||
|
skip += page.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Освобождает ресурсы диалога (подписки, сетевые хэндлы). Идемпотентно.
|
||||||
|
* После [close] дальнейшие вызовы [send]/[interrupt]/[events]/[getMessages]/[rename] не определены.
|
||||||
|
*/
|
||||||
|
override fun close()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val PAGE_SIZE: Int = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент live-потока [Conversation.events].
|
||||||
|
*
|
||||||
|
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||||
|
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
||||||
|
* порядка при равных timestamps.
|
||||||
|
*
|
||||||
|
* Базовая структура хода:
|
||||||
|
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
||||||
|
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface Event {
|
||||||
|
/** Момент эмиссии события в UTC. */
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ResponseType {
|
||||||
|
@SerialName("text") TEXT,
|
||||||
|
@SerialName("image") IMAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("start_reasoning")
|
||||||
|
data class StartReasoning(override val date: Instant) : Event
|
||||||
|
|
||||||
|
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("start_response")
|
||||||
|
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
||||||
|
|
||||||
|
/** Ход завершён нормально. Соответствующий [Message.AssistantMessage] появится в `getMessages`. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("end")
|
||||||
|
data class End(override val date: Instant) : Event
|
||||||
|
|
||||||
|
/** Ход прерван через [Conversation.interrupt]. Частичный ответ НЕ сохраняется в истории. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("interrupted")
|
||||||
|
data class Interrupted(override val date: Instant) : Event
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append_text")
|
||||||
|
data class AppendText(override val date: Instant, val body: String) : Event
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append_image")
|
||||||
|
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||||
|
* [id] совпадает с id соответствующего [Message.ToolCall] в истории
|
||||||
|
* после завершения хода.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_call")
|
||||||
|
data class ToolCall(
|
||||||
|
override val date: Instant,
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val toolName: String,
|
||||||
|
val toolArgs: String,
|
||||||
|
) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
||||||
|
* [id] совпадает с [ToolCall.id], к которому относится результат, и
|
||||||
|
* с id [Message.ToolResult] в истории.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_result")
|
||||||
|
data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
||||||
|
* прийти, но ход считается проваленным.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("error")
|
||||||
|
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface Message {
|
||||||
|
/**
|
||||||
|
* Уникальный идентификатор сообщения в рамках диалога. Стабилен между
|
||||||
|
* стримом [Event] и историей: id, пришедший в [Event.ToolCall], равен
|
||||||
|
* id соответствующего [ToolCall] в истории после завершения хода.
|
||||||
|
*/
|
||||||
|
val id: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата сообщения в UTC
|
||||||
|
*/
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("user_message")
|
||||||
|
class UserMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("assistant_message")
|
||||||
|
class AssistantMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_call")
|
||||||
|
class ToolCall(
|
||||||
|
override val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val toolName: String,
|
||||||
|
val toolArgs: String,
|
||||||
|
override val date: Instant
|
||||||
|
) : Message
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_result")
|
||||||
|
class ToolResult(override val id: String, val result: String?, override val date: Instant) : Message
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.ktor.server.core)
|
||||||
|
implementation(libs.ktor.server.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-снимок [Conversation] — те же поля, что у интерфейса, но без методов.
|
||||||
|
* Сериализуется в JSON и обратно.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ConversationSnapshot(
|
||||||
|
val id: String,
|
||||||
|
val isSupportImageInput: Boolean,
|
||||||
|
val isSupportImageOutput: Boolean,
|
||||||
|
val isTemporal: Boolean,
|
||||||
|
val title: String? = null,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun Conversation.snapshot(): ConversationSnapshot = ConversationSnapshot(
|
||||||
|
id = id,
|
||||||
|
isSupportImageInput = isSupportImageInput,
|
||||||
|
isSupportImageOutput = isSupportImageOutput,
|
||||||
|
isTemporal = isTemporal,
|
||||||
|
title = title,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RequestCreateConversation(val temp: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RequestRename(val title: String)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.route
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Встраивает HTTP/SSE-фасад протокола agentik в твой Ktor-роутинг.
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* ```
|
||||||
|
* embeddedServer(Netty, port = 8080) {
|
||||||
|
* routing {
|
||||||
|
* agentikAgent(MyAgent()) // все роуты под /agentik
|
||||||
|
* agentikAgent(MyAgent(), "/api/chat") // или под произвольным префиксом
|
||||||
|
* }
|
||||||
|
* }.start(wait = true)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Под префиксом [path] монтируются:
|
||||||
|
* - `POST /conversations` — создать диалог
|
||||||
|
* - `GET /conversations` — список
|
||||||
|
* - `GET /conversations/{id}` — один диалог
|
||||||
|
* - `PATCH /conversations/{id}` — переименовать
|
||||||
|
* - `DELETE /conversations/{id}` — удалить
|
||||||
|
* - `POST /conversations/{id}/messages` — `send` (202 Accepted)
|
||||||
|
* - `POST /conversations/{id}/interrupt` — `interrupt`
|
||||||
|
* - `GET /conversations/{id}/messages` — история
|
||||||
|
* - `GET /conversations/{id}/events` — SSE: события хода
|
||||||
|
* - `GET /events` — SSE: события агента
|
||||||
|
* - `GET /health` — `"ok"`
|
||||||
|
*/
|
||||||
|
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") {
|
||||||
|
route(path) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(agentikJson)
|
||||||
|
}
|
||||||
|
agentikRoutes(agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.ApplicationCall
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.request.receive
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.response.respondTextWriter
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.delete
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.patch
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
internal fun Route.agentikRoutes(agent: Agent) {
|
||||||
|
|
||||||
|
get("/health") {
|
||||||
|
call.respondText("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Agent: множество диалогов ----
|
||||||
|
|
||||||
|
get("/conversations") {
|
||||||
|
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||||
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Agent.PAGE_SIZE
|
||||||
|
call.respond(agent.getConversations(offset, limit).map { it.snapshot() })
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/conversations") {
|
||||||
|
val req = call.receive<RequestCreateConversation>()
|
||||||
|
val c = agent.createConversation(req.temp)
|
||||||
|
call.respond(HttpStatusCode.Created, c.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/conversations/{id}") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val c = agent.getConversation(id)
|
||||||
|
if (c == null) call.respond(HttpStatusCode.NotFound)
|
||||||
|
else call.respond(c.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
delete("/conversations/{id}") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
call.respond(if (agent.deleteConversation(id)) HttpStatusCode.NoContent else HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Conversation ----
|
||||||
|
|
||||||
|
patch("/conversations/{id}") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val c = agent.getConversation(id)
|
||||||
|
if (c == null) {
|
||||||
|
call.respond(HttpStatusCode.NotFound)
|
||||||
|
return@patch
|
||||||
|
}
|
||||||
|
val req = call.receive<RequestRename>()
|
||||||
|
c.rename(req.title)
|
||||||
|
call.respond(c.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/conversations/{id}/messages") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val c = agent.getConversation(id)
|
||||||
|
if (c == null) {
|
||||||
|
call.respond(HttpStatusCode.NotFound)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val content = call.receive<List<Content>>()
|
||||||
|
c.send(content)
|
||||||
|
call.respond(HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/conversations/{id}/interrupt") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val c = agent.getConversation(id)
|
||||||
|
if (c == null) {
|
||||||
|
call.respond(HttpStatusCode.NotFound)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
c.interrupt()
|
||||||
|
call.respond(HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/conversations/{id}/messages") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val c = agent.getConversation(id)
|
||||||
|
if (c == null) {
|
||||||
|
call.respond(HttpStatusCode.NotFound)
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
val after = call.parseAfter() ?: return@get
|
||||||
|
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||||
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Conversation.PAGE_SIZE
|
||||||
|
call.respond(c.getMessages(after, offset, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SSE ----
|
||||||
|
|
||||||
|
get("/conversations/{id}/events") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val c = agent.getConversation(id)
|
||||||
|
if (c == null) {
|
||||||
|
call.respond(HttpStatusCode.NotFound)
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
val after = call.parseAfter() ?: return@get
|
||||||
|
call.streamJsonSse(c.events(after), Event.serializer())
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/events") {
|
||||||
|
val after = call.parseAfter() ?: return@get
|
||||||
|
call.streamJsonSse(agent.events(after), AgentEvent.serializer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит query-параметр `after` как ISO-8601 [Instant]. Отсутствие = [Instant.DISTANT_PAST].
|
||||||
|
* При невалидном значении отвечает 400 и возвращает `null`.
|
||||||
|
*/
|
||||||
|
private suspend fun ApplicationCall.parseAfter(): Instant? {
|
||||||
|
val raw = request.queryParameters["after"]
|
||||||
|
if (raw == null) return Instant.DISTANT_PAST
|
||||||
|
return try {
|
||||||
|
Instant.parse(raw)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
respond(HttpStatusCode.BadRequest, "Invalid 'after' (expected ISO-8601): $raw")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> ApplicationCall.streamJsonSse(
|
||||||
|
flow: Flow<T>,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
json: Json = agentikJson,
|
||||||
|
) {
|
||||||
|
respondTextWriter(contentType = ContentType.Text.EventStream) {
|
||||||
|
flow.catch { /* клиент отвалился — глушим */ }
|
||||||
|
.collect { value ->
|
||||||
|
val s = json.encodeToString(serializer, value)
|
||||||
|
write("data: ")
|
||||||
|
write(s)
|
||||||
|
write("\n\n")
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
internal object InstantSerializer : KSerializer<Instant> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: Instant) =
|
||||||
|
encoder.encodeString(value.toString())
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): Instant =
|
||||||
|
Instant.parse(decoder.decodeString())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val agentikJson: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
serializersModule = SerializersModule {
|
||||||
|
contextual(Instant::class, InstantSerializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
val caffeineRepo = providers.gradleProperty("caffeineRepo").getOrElse("http://192.168.76.117/repository/caffeine")
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
google()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
google()
|
||||||
|
// Home Nexus, репо "caffeine": pw.binom.* (AG-UI, A2A, ...)
|
||||||
|
maven {
|
||||||
|
name = "caffeine"
|
||||||
|
url = uri(caffeineRepo)
|
||||||
|
setAllowInsecureProtocol(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "agentik"
|
||||||
|
|
||||||
|
include(":standalone")
|
||||||
|
// Собственный протокол agentik (замена AG-UI). Пока в нём пилим, потом вынесем.
|
||||||
|
include(":proto")
|
||||||
|
// Ktor-сервер, экспонирующий Agent по HTTP (SSE + JSON).
|
||||||
|
include(":server")
|
||||||
|
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
|
||||||
|
include(":client")
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.sqldelight)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm {
|
||||||
|
binaries {
|
||||||
|
executable {
|
||||||
|
mainClass.set("pw.binom.agentik.standalone.MainKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
implementation(project(":server"))
|
||||||
|
|
||||||
|
// Commons
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
|
// litert-kmp: контракт (commonMain)
|
||||||
|
api(libs.litert.api)
|
||||||
|
|
||||||
|
// SQLDelight runtime (commonMain)
|
||||||
|
api(libs.sqldelight.runtime)
|
||||||
|
api(libs.sqldelight.coroutines)
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmMain.dependencies {
|
||||||
|
// litert-openai: JVM-реализация
|
||||||
|
implementation(libs.litert.openai)
|
||||||
|
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
||||||
|
runtimeOnly(libs.litert.google)
|
||||||
|
|
||||||
|
// SQLDelight JDBC driver (JVM)
|
||||||
|
implementation(libs.sqldelight.sqlite.driver)
|
||||||
|
|
||||||
|
// Ktor server (для :server facade + a2aServer)
|
||||||
|
implementation(libs.ktor.server.core)
|
||||||
|
implementation(libs.ktor.server.sse)
|
||||||
|
implementation(libs.ktor.server.netty)
|
||||||
|
implementation(libs.ktor.server.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
|
// Транспортные фасады
|
||||||
|
implementation(libs.agui.server)
|
||||||
|
implementation(libs.a2a.server)
|
||||||
|
|
||||||
|
// MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов
|
||||||
|
implementation(libs.mcp.sdk.client)
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
// Ktor test engine для smoke-тестов HTTP
|
||||||
|
implementation(libs.ktor.server.test.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqldelight {
|
||||||
|
databases {
|
||||||
|
create("AgentikDatabase") {
|
||||||
|
packageName.set("pw.binom.agentik.standalone.persistence.sqlite")
|
||||||
|
srcDirs.setFrom("src/jvmMain/sqldelight")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Часть контента сообщения на уровне хранилища.
|
||||||
|
*
|
||||||
|
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
|
||||||
|
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
|
||||||
|
* идентичны, но даёт возможность заменить transport-протокол без миграции
|
||||||
|
* таблиц.
|
||||||
|
*
|
||||||
|
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
|
||||||
|
*/
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot диалога. В таблице `conversation` хранится как есть.
|
||||||
|
*/
|
||||||
|
data class ConversationRecord(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val isTemporal: Boolean,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD по таблице `conversation`.
|
||||||
|
*/
|
||||||
|
interface ConversationStore : AutoCloseable {
|
||||||
|
|
||||||
|
/** Создать или обновить snapshot диалога. */
|
||||||
|
suspend fun upsert(record: ConversationRecord)
|
||||||
|
|
||||||
|
/** Диалог по id, или `null`. */
|
||||||
|
suspend fun get(id: String): ConversationRecord?
|
||||||
|
|
||||||
|
/** Удалить диалог (вместе с его сообщениями и working memory). */
|
||||||
|
suspend fun delete(id: String): Boolean
|
||||||
|
|
||||||
|
/** Список диалогов, отсортированный по `updatedAt` DESC. */
|
||||||
|
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
|
||||||
|
|
||||||
|
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
|
||||||
|
suspend fun rename(id: String, title: String?): Instant?
|
||||||
|
|
||||||
|
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
|
||||||
|
suspend fun touch(id: String, now: Instant)
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись в таблице `message` (append-only audit) и `working_memory` (mutable view).
|
||||||
|
*
|
||||||
|
* Используется sealed-иерархия: подтипы `User`/`Assistant`/`ToolCall`/`ToolResult`
|
||||||
|
* живут и там, и там. `Summary`/`System` — только в `working_memory`
|
||||||
|
* (синтетические строки, созданные при суммаризации или как system-prompt).
|
||||||
|
*
|
||||||
|
* Все подтипы несут [id] (UUID, стабильный между лайв-стримом Event и историей),
|
||||||
|
* [conversationId] и [createdAt].
|
||||||
|
*
|
||||||
|
* Поля, специфичные для подтипа, сериализуются в JSON в `payload_json`
|
||||||
|
* колонке SQLite — это даёт гибкость без миграций при добавлении полей.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface MessageRecord {
|
||||||
|
|
||||||
|
val id: String
|
||||||
|
val conversationId: String
|
||||||
|
val createdAt: Instant
|
||||||
|
|
||||||
|
/** Подтип сообщения с телом из [Content]. */
|
||||||
|
@Serializable
|
||||||
|
sealed interface Body : MessageRecord {
|
||||||
|
val content: List<Content>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("user")
|
||||||
|
data class UserMessage(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
override val content: List<Content>,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : Body
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("assistant")
|
||||||
|
data class AssistantMessage(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
override val content: List<Content>,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : Body
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_call")
|
||||||
|
data class ToolCall(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val toolName: String,
|
||||||
|
val toolTitle: String?,
|
||||||
|
val toolArgsJson: String,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_result")
|
||||||
|
data class ToolResult(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val toolCallId: String,
|
||||||
|
val result: String?,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
|
||||||
|
/** Синтетическое: суммаризация старого контекста. Только в working_memory. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("summary")
|
||||||
|
data class Summary(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val text: String,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
|
||||||
|
/** Синтетическое: system-prompt, введённый при создании диалога. Только в working_memory. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("system")
|
||||||
|
data class System(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val text: String,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log сообщений (`message` table).
|
||||||
|
*
|
||||||
|
* Только `insert` и чтение. Никаких обновлений, никакого удаления (кроме
|
||||||
|
* каскадного удаления вместе с [ConversationStore.delete]).
|
||||||
|
*/
|
||||||
|
interface MessageStore : AutoCloseable {
|
||||||
|
|
||||||
|
/** Добавить запись в audit log. `conversationId` берётся из [MessageRecord.conversationId]. */
|
||||||
|
suspend fun append(record: MessageRecord)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница audit-сообщений диалога после [after] (UTC), отсортированная
|
||||||
|
* по `createdAt ASC`. Для первоначальной загрузки передай `Instant.DISTANT_PAST`.
|
||||||
|
*/
|
||||||
|
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||||
|
|
||||||
|
/** Все сообщения диалога, отсортированные по `createdAt ASC` (для rebuild working memory). */
|
||||||
|
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-формат для тел user/assistant сообщений: список [Content],
|
||||||
|
* сериализованный в строку (через kotlinx-serialization).
|
||||||
|
*/
|
||||||
|
private val bodyJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сериализует список [Content] в JSON-строку для хранения в `payload_json`.
|
||||||
|
*/
|
||||||
|
fun encodeBodyPayload(content: List<Content>): String =
|
||||||
|
bodyJson.encodeToString(ListSerializer(Content.serializer()), content)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Десериализует список [Content] из JSON-строки `payload_json`.
|
||||||
|
*/
|
||||||
|
fun decodeBodyPayload(json: String): List<Content> =
|
||||||
|
bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||||
|
* LLM-контексте. Упорядочено по `order_idx` (заполняется в store при append).
|
||||||
|
*
|
||||||
|
* Sealed-иерархия: для v1 — `System` (синтетический system-prompt),
|
||||||
|
* `User`/`Assistant` (реплики с ссылкой на audit log через [sourceMessageId]).
|
||||||
|
* Суммаризация (для v2) добавит подтип `Summary`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface WorkingMemoryEntry {
|
||||||
|
|
||||||
|
/** Ссылка на исходное сообщение в audit log (`message.id`). `null` для синтетических строк. */
|
||||||
|
val sourceMessageId: String?
|
||||||
|
|
||||||
|
/** Синтетический system-prompt, добавляется при создании диалога. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("system")
|
||||||
|
data class System(val text: String) : WorkingMemoryEntry {
|
||||||
|
override val sourceMessageId: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реплика пользователя. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("user")
|
||||||
|
data class User(
|
||||||
|
override val sourceMessageId: String,
|
||||||
|
val content: List<Content>,
|
||||||
|
) : WorkingMemoryEntry
|
||||||
|
|
||||||
|
/** Реплика ассистента. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("assistant")
|
||||||
|
data class Assistant(
|
||||||
|
override val sourceMessageId: String,
|
||||||
|
val content: List<Content>,
|
||||||
|
) : WorkingMemoryEntry
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Одна строка `working_memory` таблицы (внутреннее представление store).
|
||||||
|
*
|
||||||
|
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
||||||
|
* Агент не должен с этим типом работать напрямую — он работает с
|
||||||
|
* [WorkingMemoryEntry] через [WorkingMemoryStore].
|
||||||
|
*/
|
||||||
|
data class WorkingMemoryRow(
|
||||||
|
val id: String,
|
||||||
|
val conversationId: String,
|
||||||
|
val orderIdx: Long,
|
||||||
|
val sourceMessageId: String?,
|
||||||
|
val entry: WorkingMemoryEntry,
|
||||||
|
val createdAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутируемое представление LLM-контекста диалога (`working_memory` table).
|
||||||
|
*
|
||||||
|
* Аудит-лог — [MessageStore], неизменный; здесь — ровно то, что агент сейчас
|
||||||
|
* «видит»: системный промпт + реплики + (опционально) суммаризации.
|
||||||
|
* Строки упорядочены по `order_idx` ASC; `source_message_id` NULL указывает
|
||||||
|
* на синтетические строки (System, а в v2 — Summary).
|
||||||
|
*
|
||||||
|
* Суммаризация / чистка — один атомарный вызов [compact].
|
||||||
|
*/
|
||||||
|
interface WorkingMemoryStore : AutoCloseable {
|
||||||
|
|
||||||
|
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
||||||
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
|
|
||||||
|
/** Все строки working memory диалога в порядке отправки. */
|
||||||
|
suspend fun list(conversationId: String): List<WorkingMemoryRow>
|
||||||
|
|
||||||
|
/** Очистить working memory диалога (используется при reset/rebuild). */
|
||||||
|
suspend fun clear(conversationId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарная суммаризация (v2): удаляет все строки с `order_idx` в диапазоне
|
||||||
|
* `[dropFromOrderIdx, +∞)` и вставляет вместо них новый `summary` с
|
||||||
|
* указанным текстом. Возвращает новый максимальный `order_idx`.
|
||||||
|
*
|
||||||
|
* Для v1 просто удаляет — суммаризация появится в v2.
|
||||||
|
*/
|
||||||
|
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package pw.binom.agentik.standalone
|
||||||
|
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.netty.Netty
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import pw.binom.agentik.server.agentikAgent
|
||||||
|
import pw.binom.agentik.standalone.agent.ChatAgent
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.standalone.mcp.McpConfig
|
||||||
|
import pw.binom.agentik.standalone.mcp.McpRegistry
|
||||||
|
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||||
|
/**
|
||||||
|
* standalone-контейнер agentik:
|
||||||
|
* - :server (proto): встраиваемый Ktor (Netty), порт AGENTIK_PORT (default 8080)
|
||||||
|
* POST /agentik/conversations -> ConversationSnapshot (201)
|
||||||
|
* GET /agentik/conversations -> [ConversationSnapshot]
|
||||||
|
* GET /agentik/conversations/{id} -> ConversationSnapshot
|
||||||
|
* PATCH /agentik/conversations/{id} -> ConversationSnapshot
|
||||||
|
* DELETE /agentik/conversations/{id} -> 204
|
||||||
|
* POST /agentik/conversations/{id}/messages -> 202
|
||||||
|
* POST /agentik/conversations/{id}/interrupt -> 202
|
||||||
|
* GET /agentik/conversations/{id}/messages -> [Message]
|
||||||
|
* GET /agentik/conversations/{id}/events -> text/event-stream (SSE)
|
||||||
|
* GET /agentik/events -> text/event-stream (SSE, Agent-level)
|
||||||
|
* GET /health -> "ok"
|
||||||
|
*
|
||||||
|
* Хранилище — SQLite (env: AGENTIK_DB_PATH, default `./agentik.db`, `:memory:` для тестов).
|
||||||
|
* LLM — litert-openai (env: OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL) или litert-google
|
||||||
|
* (env: AGENTIK_LLM_BACKEND=google, AGENTIK_GOOGLE_MODEL_PATH).
|
||||||
|
* MCP — AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop).
|
||||||
|
*
|
||||||
|
* System prompt — AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`).
|
||||||
|
*/
|
||||||
|
fun main() {
|
||||||
|
val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080
|
||||||
|
val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db"
|
||||||
|
|
||||||
|
val llmConfig = LlmConfig.fromEnv()
|
||||||
|
val llm = llmConfig.createLlm()
|
||||||
|
val stores = SqliteStores.open(dbPath = dbPath)
|
||||||
|
val mcpRegistry = McpRegistry.fromConfig(McpConfig.fromEnv())
|
||||||
|
val agent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = llm,
|
||||||
|
llmConfig = llmConfig,
|
||||||
|
tools = mcpRegistry.namedTools,
|
||||||
|
)
|
||||||
|
|
||||||
|
val server = embeddedServer(Netty, port = port) {
|
||||||
|
routing {
|
||||||
|
get("/health") { call.respondText("ok") }
|
||||||
|
agentikAgent(agent, path = "/agentik")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("agentik standalone listening on http://localhost:$port")
|
||||||
|
println(" GET /health")
|
||||||
|
println(" POST /agentik/conversations -> 201")
|
||||||
|
println(" GET /agentik/conversations/{id}/events -> SSE")
|
||||||
|
println(" storage: $dbPath")
|
||||||
|
println(" llm: ${llmConfig.backend} ${llmConfig.modelInfo()}")
|
||||||
|
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
|
||||||
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
|
agent.close()
|
||||||
|
mcpRegistry.close()
|
||||||
|
stores.close()
|
||||||
|
llm.close()
|
||||||
|
})
|
||||||
|
server.start(wait = true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.proto.Agent as ProtoAgent
|
||||||
|
import pw.binom.agentik.proto.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.standalone.persistence.ConversationRecord
|
||||||
|
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
|
||||||
|
* [LiteLlm] (универсальный LLM-контракт).
|
||||||
|
*
|
||||||
|
* Создаёт [ChatConversation] — те самые stateful диалоги, которые
|
||||||
|
* хранят свой собственный LiteConversation в ОЗУ (для скорости) и
|
||||||
|
* логируют в SQLite (для долговечности).
|
||||||
|
*
|
||||||
|
* Один [LiteLlm] шарится между всеми беседами агента.
|
||||||
|
*/
|
||||||
|
class ChatAgent(
|
||||||
|
override val id: String,
|
||||||
|
private val stores: SqliteStores,
|
||||||
|
private val llm: LiteLlm,
|
||||||
|
private val llmConfig: LlmConfig,
|
||||||
|
private val tools: List<NamedTool> = emptyList(),
|
||||||
|
) : ProtoAgent, AutoCloseable {
|
||||||
|
|
||||||
|
private val agentEvents = MutableSharedFlow<AgentEvent>(
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Защищает карту живых диалогов. */
|
||||||
|
private val liveLock = Mutex()
|
||||||
|
private val live: MutableMap<String, ChatConversation> = HashMap()
|
||||||
|
|
||||||
|
/** Live-подписка на события уровня агента (создание/удаление/переименование). */
|
||||||
|
override fun events(after: Instant): Flow<AgentEvent> {
|
||||||
|
// Реализация событийной шины упрощённая: возвращаем общий поток.
|
||||||
|
// Фильтр по `after` не делаем — для v1 после-семантика не нужна
|
||||||
|
// (см. Memory #3704: replay-free, бэкфилл через getConversations/getConversation).
|
||||||
|
return agentEvents.asSharedFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createConversation(temp: Boolean): ProtoConversation {
|
||||||
|
val now = now()
|
||||||
|
val id = "conv-${java.util.UUID.randomUUID()}"
|
||||||
|
val rec = ConversationRecord(
|
||||||
|
id = id,
|
||||||
|
title = null,
|
||||||
|
isTemporal = temp,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
// Temp-беседы не пишем в SQLite — они живут только в RAM-карте `live`
|
||||||
|
// и не переживают рестарт агента (см. Memory #3709).
|
||||||
|
if (!temp) {
|
||||||
|
runBlocking {
|
||||||
|
stores.conversations.upsert(rec)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = id,
|
||||||
|
entry = WorkingMemoryEntry.System(text = llmConfig.systemPrompt),
|
||||||
|
now = now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools)
|
||||||
|
runBlocking {
|
||||||
|
liveLock.withLock { live[conv.id] = conv }
|
||||||
|
}
|
||||||
|
agentEvents.tryEmit(AgentEvent.Created(date = now(), conversationId = conv.id))
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversation(id: String): ProtoConversation? {
|
||||||
|
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
|
||||||
|
val rec = stores.conversations.get(id) ?: return null
|
||||||
|
return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools).also {
|
||||||
|
liveLock.withLock { live[id] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean {
|
||||||
|
val conv = liveLock.withLock { live.remove(id) }
|
||||||
|
conv?.close()
|
||||||
|
val ok = stores.conversations.delete(id)
|
||||||
|
if (ok) agentEvents.tryEmit(AgentEvent.Deleted(date = now(), id = id))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
|
||||||
|
stores.conversations.list(offset = offset, limit = limit).map { rec ->
|
||||||
|
liveLock.withLock { live[rec.id] }
|
||||||
|
?: ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt).also {
|
||||||
|
liveLock.withLock { live[rec.id] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runBlocking {
|
||||||
|
liveLock.withLock {
|
||||||
|
live.values.forEach { it.close() }
|
||||||
|
live.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching { llm.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun unregister(id: String) {
|
||||||
|
runBlocking { liveLock.withLock { live.remove(id) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun now(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.proto.Content as ProtoContent
|
||||||
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
|
import pw.binom.agentik.proto.Event as ProtoEvent
|
||||||
|
import pw.binom.agentik.proto.Message as ProtoMessage
|
||||||
|
import pw.binom.agentik.standalone.persistence.Content
|
||||||
|
import pw.binom.agentik.standalone.persistence.ConversationRecord
|
||||||
|
import pw.binom.agentik.standalone.persistence.ConversationStore
|
||||||
|
import pw.binom.agentik.standalone.persistence.MessageRecord
|
||||||
|
import pw.binom.agentik.standalone.persistence.MessageStore
|
||||||
|
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
|
||||||
|
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||||
|
import pw.binom.litert.LiteContentPart
|
||||||
|
import pw.binom.litert.LiteConversation
|
||||||
|
import pw.binom.litert.LiteConversationConfig
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.LiteMessage
|
||||||
|
import pw.binom.litert.LiteRole
|
||||||
|
import pw.binom.litert.LiteToolCall
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful [pw.binom.agentik.proto.Conversation] поверх [LiteLlm].
|
||||||
|
*
|
||||||
|
* Один [LiteConversation] живёт всю беседу (требование абстракции LiteLlm — у реализаций
|
||||||
|
* внутри `LiteConversation` хранится KV-cache движка / инкрементальная история).
|
||||||
|
*
|
||||||
|
* На каждый [send]:
|
||||||
|
* 1. Записывает user-сообщение в audit log + working memory (если не temp).
|
||||||
|
* 2. Создаёт [LiteConversation] **один раз** при первом send с initial messages из
|
||||||
|
* working memory (без только что записанного user-сообщения — мы его отдадим через
|
||||||
|
* [LiteConversation.sendStreamContents]).
|
||||||
|
* 3. Стримит ответ [LiteDelta]:
|
||||||
|
* - text → [ProtoEvent.AppendText]
|
||||||
|
* - toolCalls → выполняем через [tools] (MCP), эмитим [ProtoEvent.ToolCall]/[ProtoEvent.ToolResult],
|
||||||
|
* подаём результат через [LiteConversation.addToolResult], продолжаем стрим
|
||||||
|
* до тех пор, пока модель не перестанет вызывать тулы.
|
||||||
|
* 4. По завершении записывает AssistantMessage в audit + working memory.
|
||||||
|
*
|
||||||
|
* При ошибке LiteConversation выбрасывается и пересоздаётся на следующий [send].
|
||||||
|
*/
|
||||||
|
class ChatConversation(
|
||||||
|
record: ConversationRecord,
|
||||||
|
private val stores: SqliteStores,
|
||||||
|
private val llm: LiteLlm,
|
||||||
|
private val systemPrompt: String,
|
||||||
|
private val tools: List<NamedTool> = emptyList(),
|
||||||
|
) : ProtoConversation, AutoCloseable {
|
||||||
|
|
||||||
|
private var record: ConversationRecord = record
|
||||||
|
|
||||||
|
override val id: String get() = record.id
|
||||||
|
override val isSupportImageInput: Boolean get() = false
|
||||||
|
override val isSupportImageOutput: Boolean get() = false
|
||||||
|
override val isTemporal: Boolean get() = record.isTemporal
|
||||||
|
override val title: String? get() = record.title
|
||||||
|
override val updatedAt: Instant get() = record.updatedAt
|
||||||
|
|
||||||
|
private val conversationStore: ConversationStore get() = stores.conversations
|
||||||
|
private val messageStore: MessageStore get() = stores.messages
|
||||||
|
private val workingMemory: WorkingMemoryStore get() = stores.workingMemory
|
||||||
|
|
||||||
|
private val toolsByName: Map<String, NamedTool> = tools.associateBy { it.name }
|
||||||
|
|
||||||
|
private val events = MutableSharedFlow<ProtoEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 256,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val turnLock = Mutex()
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var liteConv: LiteConversation? = null
|
||||||
|
@Volatile
|
||||||
|
private var activeTurn: Job? = null
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
internal val isClosed: Boolean get() = closed
|
||||||
|
|
||||||
|
override suspend fun rename(title: String) {
|
||||||
|
val newRecord = conversationStore.rename(id, title)?.let { ts ->
|
||||||
|
record.copy(title = title, updatedAt = ts)
|
||||||
|
} ?: record.copy(title = title)
|
||||||
|
record = newRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(content: List<ProtoContent>) {
|
||||||
|
check(!closed) { "Conversation closed: $id" }
|
||||||
|
val turnStarted = now()
|
||||||
|
val userMessageId = newId("msg")
|
||||||
|
|
||||||
|
val userRecord = MessageRecord.UserMessage(
|
||||||
|
id = userMessageId,
|
||||||
|
conversationId = id,
|
||||||
|
content = content.map { it.toStorage() },
|
||||||
|
createdAt = turnStarted,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!record.isTemporal) {
|
||||||
|
messageStore.append(userRecord)
|
||||||
|
workingMemory.append(
|
||||||
|
conversationId = id,
|
||||||
|
entry = WorkingMemoryEntry.User(
|
||||||
|
sourceMessageId = userMessageId,
|
||||||
|
content = userRecord.content,
|
||||||
|
),
|
||||||
|
now = turnStarted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTurn = scope.launch {
|
||||||
|
turnLock.withLock {
|
||||||
|
runTurn(userRecord, turnStarted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeTurn?.join()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() {
|
||||||
|
runCatching { liteConv?.cancel() }
|
||||||
|
activeTurn?.cancel()
|
||||||
|
emitEvent(ProtoEvent.Interrupted(date = now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<ProtoEvent> =
|
||||||
|
events.asSharedFlow()
|
||||||
|
|
||||||
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<ProtoMessage> =
|
||||||
|
messageStore.list(conversationId = id, after = after, offset = offset, limit = limit)
|
||||||
|
.map { it.toProto() }
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
runCatching { liteConv?.close() }
|
||||||
|
runCatching { activeTurn?.cancel() }
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Один ход: user → (assistant → tool → ... → assistant)*.
|
||||||
|
*
|
||||||
|
* Tool-loop: после каждого `sendStreamContents` смотрим `delta.toolCalls`. Если есть —
|
||||||
|
* исполняем, подаём результат через `addToolResult`, делаем ещё один send (с пустым
|
||||||
|
* user-сообщением как триггером продолжения — модель уже знает, что делать дальше
|
||||||
|
* по tool-results в истории), повторяем. Защита от зацикливания — [MAX_TOOL_LOOPS].
|
||||||
|
*/
|
||||||
|
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
|
||||||
|
emitEvent(ProtoEvent.StartReasoning(date = turnStarted))
|
||||||
|
emitEvent(ProtoEvent.StartResponse(date = now(), responseType = ProtoEvent.ResponseType.TEXT))
|
||||||
|
|
||||||
|
val parts = userRecord.content.mapNotNull { c ->
|
||||||
|
when (c) {
|
||||||
|
is Content.Text -> LiteContentPart.Text(c.body)
|
||||||
|
is Content.Image -> {
|
||||||
|
System.err.println("[agentik] dropping image input (v1 text-only): mime=${c.mime}, ${c.data.size} bytes")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
emitEvent(ProtoEvent.Error(date = now(), message = "Empty user input (no text content)"))
|
||||||
|
emitEvent(ProtoEvent.End(date = now()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val liteConv = try {
|
||||||
|
getOrCreateLiteConversation(excludeUserSourceId = if (record.isTemporal) null else userRecord.id)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
this.liteConv = null
|
||||||
|
emitEvent(ProtoEvent.Error(date = now(), message = e.message ?: "LiteConversation init failed"))
|
||||||
|
emitEvent(ProtoEvent.End(date = now()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val reply = StringBuilder()
|
||||||
|
var currentParts: List<LiteContentPart> = parts
|
||||||
|
var loopGuard = 0
|
||||||
|
|
||||||
|
while (loopGuard++ < MAX_TOOL_LOOPS) {
|
||||||
|
val collectedCalls = mutableListOf<LiteToolCall>()
|
||||||
|
try {
|
||||||
|
liteConv.sendStreamContents(currentParts).collect { delta ->
|
||||||
|
if (delta.text.isNotEmpty()) {
|
||||||
|
reply.append(delta.text)
|
||||||
|
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
|
||||||
|
}
|
||||||
|
if (delta.toolCalls.isNotEmpty()) {
|
||||||
|
collectedCalls.addAll(delta.toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
emitEvent(ProtoEvent.Error(date = now(), message = e.message ?: e.javaClass.simpleName))
|
||||||
|
emitEvent(ProtoEvent.End(date = now()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectedCalls.isEmpty()) break
|
||||||
|
|
||||||
|
for (call in collectedCalls) {
|
||||||
|
executeToolCall(liteConv, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuation: send a no-op user message so the engine produces the next
|
||||||
|
// assistant response (which will see the tool results we just fed via
|
||||||
|
// addToolResult in its history). The leading newline + space is a benign
|
||||||
|
// trigger — every LLM treats it as "please continue".
|
||||||
|
currentParts = listOf(LiteContentPart.Text(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loopGuard >= MAX_TOOL_LOOPS) {
|
||||||
|
System.err.println("[agentik] tool loop hit MAX_TOOL_LOOPS=$MAX_TOOL_LOOPS for $id — bailing")
|
||||||
|
}
|
||||||
|
|
||||||
|
val assistantId = newId("msg")
|
||||||
|
val assistantAt = now()
|
||||||
|
val assistantContent = listOf(Content.Text(reply.toString()))
|
||||||
|
val assistantRecord = MessageRecord.AssistantMessage(
|
||||||
|
id = assistantId,
|
||||||
|
conversationId = id,
|
||||||
|
content = assistantContent,
|
||||||
|
createdAt = assistantAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!record.isTemporal) {
|
||||||
|
messageStore.append(assistantRecord)
|
||||||
|
workingMemory.append(
|
||||||
|
conversationId = id,
|
||||||
|
entry = WorkingMemoryEntry.Assistant(
|
||||||
|
sourceMessageId = assistantId,
|
||||||
|
content = assistantContent,
|
||||||
|
),
|
||||||
|
now = assistantAt,
|
||||||
|
)
|
||||||
|
record = record.copy(updatedAt = assistantAt)
|
||||||
|
conversationStore.touch(id, assistantAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent(ProtoEvent.End(date = assistantAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult,
|
||||||
|
* пишем в audit + working memory, подаём результат в LiteConversation.
|
||||||
|
*/
|
||||||
|
private suspend fun executeToolCall(liteConv: LiteConversation, call: LiteToolCall) {
|
||||||
|
val callId = newId("tc")
|
||||||
|
val resultId = newId("tr")
|
||||||
|
val argsJson = encodeArgsJson(call.arguments)
|
||||||
|
val nowTs = now()
|
||||||
|
|
||||||
|
emitEvent(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson))
|
||||||
|
|
||||||
|
if (!record.isTemporal) {
|
||||||
|
messageStore.append(
|
||||||
|
MessageRecord.ToolCall(
|
||||||
|
id = callId,
|
||||||
|
conversationId = id,
|
||||||
|
toolName = call.name,
|
||||||
|
toolTitle = null,
|
||||||
|
toolArgsJson = argsJson,
|
||||||
|
createdAt = nowTs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tool = toolsByName[call.name]
|
||||||
|
val resultText: String = if (tool == null) {
|
||||||
|
System.err.println("[agentik] tool '${call.name}' requested but not registered")
|
||||||
|
"[tool not found: ${call.name}]"
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
tool.tool.invoke(argsJson).ifBlank { "<empty result>" }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
System.err.println("[agentik] tool '${call.name}' threw: ${e.message}")
|
||||||
|
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent(ProtoEvent.ToolResult(date = now(), id = resultId, result = resultText))
|
||||||
|
|
||||||
|
if (!record.isTemporal) {
|
||||||
|
messageStore.append(
|
||||||
|
MessageRecord.ToolResult(
|
||||||
|
id = resultId,
|
||||||
|
conversationId = id,
|
||||||
|
toolCallId = callId,
|
||||||
|
result = resultText,
|
||||||
|
createdAt = now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
liteConv.addToolResult(callId = callId, name = call.name, result = resultText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает существующий [LiteConversation] или создаёт новый, инициализированный
|
||||||
|
* системным промптом и прошлыми User/Assistant из working memory.
|
||||||
|
*/
|
||||||
|
private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation {
|
||||||
|
liteConv?.let { return it }
|
||||||
|
|
||||||
|
val wm = if (record.isTemporal) emptyList() else workingMemory.list(id)
|
||||||
|
val resolvedSystemPrompt = if (record.isTemporal) systemPrompt else wm
|
||||||
|
.firstOrNull { it.entry is WorkingMemoryEntry.System }
|
||||||
|
?.let { (it.entry as WorkingMemoryEntry.System).text }
|
||||||
|
?: systemPrompt
|
||||||
|
|
||||||
|
val pastTurns = if (record.isTemporal) emptyList() else wm
|
||||||
|
.filter { row ->
|
||||||
|
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
|
||||||
|
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
|
||||||
|
isUserOrAssistant && !isPendingUser
|
||||||
|
}
|
||||||
|
.map { row ->
|
||||||
|
when (val e = row.entry) {
|
||||||
|
is WorkingMemoryEntry.User -> LiteMessage(LiteRole.USER, e.content.toLiteContents())
|
||||||
|
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
|
||||||
|
else -> error("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = LiteConversationConfig(
|
||||||
|
systemInstruction = resolvedSystemPrompt.takeIf { it.isNotBlank() },
|
||||||
|
initialMessages = pastTurns,
|
||||||
|
tools = tools.map { it.tool },
|
||||||
|
)
|
||||||
|
|
||||||
|
return llm.createConversation(config).also { liteConv = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitEvent(event: ProtoEvent) {
|
||||||
|
events.tryEmit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun now(): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||||
|
|
||||||
|
private fun newId(prefix: String): String = "$prefix-${java.util.UUID.randomUUID()}"
|
||||||
|
|
||||||
|
private fun encodeArgsJson(arguments: Map<String, Any?>): String {
|
||||||
|
val el = kotlinx.serialization.json.JsonElement.serializer()
|
||||||
|
val obj = kotlinx.serialization.json.buildJsonObject {
|
||||||
|
arguments.forEach { (k, v) -> put(k, v.toJsonElement()) }
|
||||||
|
}
|
||||||
|
return kotlinx.serialization.json.Json.encodeToString(el, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Any?.toJsonElement(): kotlinx.serialization.json.JsonElement = when (this) {
|
||||||
|
null -> kotlinx.serialization.json.JsonNull
|
||||||
|
is Boolean -> kotlinx.serialization.json.JsonPrimitive(this)
|
||||||
|
is Number -> kotlinx.serialization.json.JsonPrimitive(this)
|
||||||
|
is String -> kotlinx.serialization.json.JsonPrimitive(this)
|
||||||
|
is Map<*, *> -> kotlinx.serialization.json.buildJsonObject {
|
||||||
|
this@toJsonElement.forEach { (k, v) ->
|
||||||
|
put(k.toString(), v.toJsonElement())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is List<*> -> kotlinx.serialization.json.JsonArray(this.map { it.toJsonElement() })
|
||||||
|
else -> kotlinx.serialization.json.JsonPrimitive(toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_TOOL_LOOPS = 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<Content>.toLiteContents(): List<LiteContentPart> = map { it.toLite() }
|
||||||
|
|
||||||
|
internal fun Content.toLite(): LiteContentPart = when (this) {
|
||||||
|
is Content.Text -> LiteContentPart.Text(body)
|
||||||
|
is Content.Image -> LiteContentPart.Image(data, mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Content.toProto(): ProtoContent = when (this) {
|
||||||
|
is Content.Text -> ProtoContent.Text(body = body)
|
||||||
|
is Content.Image -> ProtoContent.Image(data = data, mime = mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ProtoContent.toStorage(): Content = when (this) {
|
||||||
|
is ProtoContent.Text -> Content.Text(body)
|
||||||
|
is ProtoContent.Image -> Content.Image(data, mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MessageRecord.toProto(): ProtoMessage = when (this) {
|
||||||
|
is MessageRecord.UserMessage -> ProtoMessage.UserMessage(
|
||||||
|
id = id,
|
||||||
|
date = createdAt,
|
||||||
|
content = content.map { it.toProto() },
|
||||||
|
)
|
||||||
|
is MessageRecord.AssistantMessage -> ProtoMessage.AssistantMessage(
|
||||||
|
id = id,
|
||||||
|
date = createdAt,
|
||||||
|
content = content.map { it.toProto() },
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolCall -> ProtoMessage.ToolCall(
|
||||||
|
id = id,
|
||||||
|
date = createdAt,
|
||||||
|
title = toolTitle,
|
||||||
|
toolName = toolName,
|
||||||
|
toolArgs = toolArgsJson,
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolResult -> ProtoMessage.ToolResult(
|
||||||
|
id = id,
|
||||||
|
date = createdAt,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
|
is MessageRecord.Summary -> ProtoMessage.AssistantMessage(
|
||||||
|
id = id,
|
||||||
|
date = createdAt,
|
||||||
|
content = listOf(ProtoContent.Text(body = text)),
|
||||||
|
)
|
||||||
|
is MessageRecord.System -> ProtoMessage.UserMessage(
|
||||||
|
id = id,
|
||||||
|
date = createdAt,
|
||||||
|
content = listOf(ProtoContent.Text(body = text)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (имя-как-видит-модель) → [LiteTool].
|
||||||
|
*
|
||||||
|
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
|
||||||
|
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
|
||||||
|
* чтобы избежать коллизий между разными MCP-серверами.
|
||||||
|
*/
|
||||||
|
data class NamedTool(val name: String, val tool: LiteTool)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pw.binom.agentik.standalone.llm
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteConfig
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory для движка Google LiteRT-LM на JVM.
|
||||||
|
*
|
||||||
|
* `litert-google` опубликован только как Android AAR (с .so внутри), поэтому на JVM
|
||||||
|
* приходится вручную подгружать JNI-библиотеки LiteRT-LM, прежде чем инстанциировать
|
||||||
|
* движок. Эта функция:
|
||||||
|
*
|
||||||
|
* 1. Резолвит `pw.binom.litert.google.GoogleLiteLlm` через reflection.
|
||||||
|
* 2. Если процесс уже загрузил нативные библиотеки (`-Djava.library.path`) — успешно
|
||||||
|
* создаёт движок.
|
||||||
|
* 3. Если нет — кидает `IllegalStateException` с инструкцией по настройке.
|
||||||
|
*/
|
||||||
|
fun googleLiteLlmJvm(config: LiteConfig): LiteLlm {
|
||||||
|
val cls = try {
|
||||||
|
Class.forName("pw.binom.litert.google.GoogleLiteLlm")
|
||||||
|
} catch (e: ClassNotFoundException) {
|
||||||
|
error(
|
||||||
|
"litert-google classes are not on the classpath. " +
|
||||||
|
"Add 'pw.binom.litert:litert-google-android:6' as a runtime dependency " +
|
||||||
|
"to use the GOOGLE backend on JVM."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val ctor = cls.constructors.firstOrNull { it.parameterCount == 1 }
|
||||||
|
?: error("pw.binom.litert.google.GoogleLiteLlm constructor not found")
|
||||||
|
val engine = try {
|
||||||
|
ctor.newInstance(config)
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
throw IllegalStateException(
|
||||||
|
"LiteRT-LM native libraries are not loaded. " +
|
||||||
|
"Extract .so/.dylib/.dll from litertlm-android-0.16.1.aar and pass " +
|
||||||
|
"-Djava.library.path=<dir>, or build :standalone for the androidJvm target.",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
} catch (e: java.lang.reflect.InvocationTargetException) {
|
||||||
|
throw e.targetException ?: e
|
||||||
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return engine as LiteLlm
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun error(message: String): Nothing = throw IllegalStateException(message)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package pw.binom.agentik.standalone.llm
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteBackend
|
||||||
|
import pw.binom.litert.LiteConfig
|
||||||
|
import pw.binom.litert.LiteExperimental
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig
|
||||||
|
import pw.binom.litert.openai.openAiLiteLlm
|
||||||
|
|
||||||
|
data class LlmConfig(
|
||||||
|
val backend: LlmBackend,
|
||||||
|
val systemPrompt: String,
|
||||||
|
val openai: LitertOpenAiConfig? = null,
|
||||||
|
val google: GoogleConfig? = null,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
when (backend) {
|
||||||
|
LlmBackend.OPENAI -> require(openai != null) { "OPENAI backend requires openai config" }
|
||||||
|
LlmBackend.GOOGLE -> require(google != null) { "GOOGLE backend requires google config" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createLlm(): LiteLlm = when (backend) {
|
||||||
|
LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai))
|
||||||
|
LlmBackend.GOOGLE -> googleLiteLlmJvm(google!!.toLiteConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modelInfo(): String = when (backend) {
|
||||||
|
LlmBackend.OPENAI -> "${checkNotNull(openai).model} @ ${checkNotNull(openai).baseUrl}"
|
||||||
|
LlmBackend.GOOGLE -> "${checkNotNull(google).modelPath}"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_SYSTEM_PROMPT: String = "Ты полезный ассистент. Отвечай кратко и по делу."
|
||||||
|
|
||||||
|
fun fromEnv(env: (String) -> String? = System::getenv): LlmConfig {
|
||||||
|
val backend = LlmBackend.parse(env("AGENTIK_LLM_BACKEND"))
|
||||||
|
val systemPromptRaw = env("AGENTIK_SYSTEM_PROMPT")
|
||||||
|
val systemPrompt = if (systemPromptRaw.isNullOrBlank()) DEFAULT_SYSTEM_PROMPT else systemPromptRaw
|
||||||
|
return when (backend) {
|
||||||
|
LlmBackend.OPENAI -> {
|
||||||
|
val openai = LitertOpenAiConfig(
|
||||||
|
baseUrl = requireEnv(env, "OPENAI_BASE_URL"),
|
||||||
|
apiKey = requireEnv(env, "OPENAI_API_KEY"),
|
||||||
|
model = requireEnv(env, "OPENAI_MODEL"),
|
||||||
|
)
|
||||||
|
LlmConfig(backend, systemPrompt, openai = openai)
|
||||||
|
}
|
||||||
|
LlmBackend.GOOGLE -> {
|
||||||
|
val google = GoogleConfig(
|
||||||
|
modelPath = requireEnv(env, "AGENTIK_GOOGLE_MODEL_PATH"),
|
||||||
|
cacheDir = env("AGENTIK_GOOGLE_CACHE_DIR"),
|
||||||
|
threads = env("AGENTIK_GOOGLE_THREADS")?.toInt(),
|
||||||
|
)
|
||||||
|
LlmConfig(backend, systemPrompt, google = google)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireEnv(env: (String) -> String?, name: String): String =
|
||||||
|
env(name) ?: error("Required env var $name is not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LlmBackend {
|
||||||
|
OPENAI,
|
||||||
|
GOOGLE;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(raw: String?): LlmBackend = when (raw?.lowercase()) {
|
||||||
|
null, "", "openai" -> OPENAI
|
||||||
|
"google", "litert", "litert-google" -> GOOGLE
|
||||||
|
else -> error("Unknown LLM backend '$raw', expected 'openai' or 'google'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GoogleConfig(
|
||||||
|
val modelPath: String,
|
||||||
|
val cacheDir: String? = null,
|
||||||
|
val threads: Int? = null,
|
||||||
|
) {
|
||||||
|
fun toLiteConfig(): LiteConfig = LiteConfig(
|
||||||
|
modelPath = modelPath,
|
||||||
|
cacheDir = cacheDir ?: "",
|
||||||
|
threads = threads,
|
||||||
|
backend = LiteBackend.CPU,
|
||||||
|
visionBackend = null,
|
||||||
|
audioBackend = null,
|
||||||
|
experimental = LiteExperimental(false, emptyMap()),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package pw.binom.agentik.standalone.mcp
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Описание одного MCP-сервера (см. [McpRegistry]).
|
||||||
|
*
|
||||||
|
* Два варианта транспорта:
|
||||||
|
* - [Stdio]: запустить процесс (`command` + `args`), общаться через stdin/stdout
|
||||||
|
* - [Http]: подключиться к удалённому MCP-серверу по URL (Streamable HTTP)
|
||||||
|
*/
|
||||||
|
sealed interface McpServerSpec {
|
||||||
|
/** Уникальное имя сервера, как его видно в логах и в tool-prefix. */
|
||||||
|
val name: String
|
||||||
|
|
||||||
|
/** stdio: спавним процесс, читаем его stdout, пишем в stdin. */
|
||||||
|
data class Stdio(
|
||||||
|
override val name: String,
|
||||||
|
val command: String,
|
||||||
|
val args: List<String> = emptyList(),
|
||||||
|
val env: Map<String, String> = emptyMap(),
|
||||||
|
) : McpServerSpec
|
||||||
|
|
||||||
|
/** http (Streamable HTTP transport): подключаемся к существующему MCP-серверу. */
|
||||||
|
data class Http(
|
||||||
|
override val name: String,
|
||||||
|
val url: String,
|
||||||
|
val headers: Map<String, String> = emptyMap(),
|
||||||
|
) : McpServerSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация MCP-слоя standalone'а.
|
||||||
|
*
|
||||||
|
* Парсится из JSON-файла в формате, совместимом с Claude Desktop
|
||||||
|
* (`mcpServers.{name}.command/args` для stdio, `mcpServers.{name}.url` для http).
|
||||||
|
*
|
||||||
|
* Путь к файлу — env `AGENTIK_MCP_CONFIG`. Если не задан или файл не существует — пустой список.
|
||||||
|
*/
|
||||||
|
data class McpConfig(
|
||||||
|
val servers: List<McpServerSpec>,
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = servers.isEmpty()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
fun fromEnv(env: (String) -> String? = System::getenv): McpConfig {
|
||||||
|
val path = env("AGENTIK_MCP_CONFIG")?.takeIf { it.isNotBlank() } ?: return empty()
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.exists()) {
|
||||||
|
System.err.println("[agentik] AGENTIK_MCP_CONFIG points to missing file: $path")
|
||||||
|
return empty()
|
||||||
|
}
|
||||||
|
return fromJson(file.readText())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun empty(): McpConfig = McpConfig(servers = emptyList())
|
||||||
|
|
||||||
|
fun fromJson(raw: String): McpConfig {
|
||||||
|
val root = json.parseToJsonElement(raw).jsonObject
|
||||||
|
val mcpServers = root["mcpServers"]?.jsonObject ?: return empty()
|
||||||
|
val servers = mcpServers.entries.mapNotNull { (name, spec) -> parseServer(name, spec.jsonObject) }
|
||||||
|
return McpConfig(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseServer(name: String, spec: JsonObject): McpServerSpec? {
|
||||||
|
val url = (spec["url"] as? JsonPrimitive)?.jsonPrimitive?.content
|
||||||
|
if (url != null) {
|
||||||
|
val headers = (spec["headers"] as? JsonObject)?.entries
|
||||||
|
?.associate { (k, v) -> k to (v as JsonPrimitive).jsonPrimitive.content }
|
||||||
|
?: emptyMap()
|
||||||
|
return McpServerSpec.Http(name = name, url = url, headers = headers)
|
||||||
|
}
|
||||||
|
val command = (spec["command"] as? JsonPrimitive)?.jsonPrimitive?.content
|
||||||
|
if (command != null) {
|
||||||
|
val args = (spec["args"] as? kotlinx.serialization.json.JsonArray)
|
||||||
|
?.map { (it as JsonPrimitive).jsonPrimitive.content }
|
||||||
|
?: emptyList()
|
||||||
|
val env = (spec["env"] as? JsonObject)?.entries
|
||||||
|
?.associate { (k, v) -> k to (v as JsonPrimitive).jsonPrimitive.content }
|
||||||
|
?: emptyMap()
|
||||||
|
return McpServerSpec.Stdio(name = name, command = command, args = args, env = env)
|
||||||
|
}
|
||||||
|
System.err.println("[agentik] MCP server '$name' has neither 'url' nor 'command' — skipped")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package pw.binom.agentik.standalone.mcp
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.defaultRequest
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.Tool
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.io.asSink
|
||||||
|
import kotlinx.io.asSource
|
||||||
|
import kotlinx.io.buffered
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import pw.binom.agentik.standalone.agent.NamedTool
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр подключённых MCP-серверов.
|
||||||
|
*
|
||||||
|
* На старте подключается ко всем [McpServerSpec] из [McpConfig], у каждого запрашивает
|
||||||
|
* список tools и оборачивает их в [LiteTool]-адаптеры ([McpLiteToolAdapter]). Все
|
||||||
|
* адаптеры собираются в [allTools], который `ChatConversation` подмешивает в
|
||||||
|
* [pw.binom.litert.LiteConversationConfig.tools].
|
||||||
|
*
|
||||||
|
* [close] убивает stdio-процессы и закрывает HTTP-клиент.
|
||||||
|
*/
|
||||||
|
class McpRegistry(
|
||||||
|
private val servers: List<McpServerSpec>,
|
||||||
|
private val httpClient: HttpClient = defaultHttpClient(),
|
||||||
|
private val clientName: String = "agentik",
|
||||||
|
private val clientVersion: String = "1.0.0",
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val connected: MutableMap<String, ConnectedServer> = ConcurrentHashMap()
|
||||||
|
@Volatile private var closed = false
|
||||||
|
|
||||||
|
/** Все [LiteTool] со всех подключённых серверов. */
|
||||||
|
val allTools: List<LiteTool> by lazy {
|
||||||
|
connected.values.flatMap { it.tools }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
|
||||||
|
val namedTools: List<NamedTool> by lazy {
|
||||||
|
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Количество успешно подключённых серверов. */
|
||||||
|
val connectedServerCount: Int get() = connected.size
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (servers.isNotEmpty()) {
|
||||||
|
for (spec in servers) {
|
||||||
|
runCatching {
|
||||||
|
val server = connectOne(spec)
|
||||||
|
connected[spec.name] = server
|
||||||
|
}.onFailure { e ->
|
||||||
|
System.err.println("[agentik] MCP server '${spec.name}' failed to connect: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.err.println("[agentik] MCP registry: ${connected.size}/${servers.size} servers connected, ${allTools.size} tools total")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectOne(spec: McpServerSpec): ConnectedServer {
|
||||||
|
var ownedProcess: Process? = null
|
||||||
|
val transport: Transport = when (spec) {
|
||||||
|
is McpServerSpec.Stdio -> {
|
||||||
|
val cmd = (listOf(spec.command) + spec.args).joinToString(" ")
|
||||||
|
System.err.println("[agentik] MCP stdio '$spec.name': $cmd")
|
||||||
|
val pb = ProcessBuilder(buildList { add(spec.command); addAll(spec.args) })
|
||||||
|
.redirectErrorStream(false)
|
||||||
|
spec.env.forEach { (k, v) -> pb.environment()[k] = v }
|
||||||
|
val proc = pb.start()
|
||||||
|
ownedProcess = proc
|
||||||
|
StdioClientTransport(
|
||||||
|
input = proc.inputStream.asSource().buffered(),
|
||||||
|
output = proc.outputStream.asSink().buffered(),
|
||||||
|
error = proc.errorStream.asSource().buffered(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is McpServerSpec.Http -> {
|
||||||
|
System.err.println("[agentik] MCP http '$spec.name': ${spec.url}")
|
||||||
|
StreamableHttpClientTransport(
|
||||||
|
client = httpClient.config {
|
||||||
|
if (spec.headers.isNotEmpty()) {
|
||||||
|
defaultRequest {
|
||||||
|
spec.headers.forEach { (k, v) ->
|
||||||
|
headers { append(k, v) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
url = spec.url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = Client(
|
||||||
|
clientInfo = Implementation(name = clientName, version = clientVersion, title = null, websiteUrl = null, icons = emptyList()),
|
||||||
|
)
|
||||||
|
runBlocking { client.connect(transport) }
|
||||||
|
val mcpTools = runBlocking { client.listTools().tools }
|
||||||
|
val liteTools = mcpTools.map { tool -> McpLiteToolAdapter(spec.name, tool, client) }
|
||||||
|
return ConnectedServer(spec, client, transport, ownedProcess, liteTools)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
connected.values.forEach { server ->
|
||||||
|
runCatching { runBlocking { server.transport.close() } }
|
||||||
|
server.ownedProcess?.let { runCatching { it.destroyForcibly() } }
|
||||||
|
}
|
||||||
|
connected.clear()
|
||||||
|
runCatching { httpClient.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ConnectedServer(
|
||||||
|
val spec: McpServerSpec,
|
||||||
|
val client: Client,
|
||||||
|
val transport: Transport,
|
||||||
|
val ownedProcess: Process?,
|
||||||
|
val tools: List<LiteTool>,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private fun defaultHttpClient(): HttpClient = HttpClient(CIO)
|
||||||
|
|
||||||
|
fun fromConfig(config: McpConfig): McpRegistry =
|
||||||
|
McpRegistry(servers = config.servers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адаптер MCP [Tool] → [pw.binom.litert.LiteTool].
|
||||||
|
*
|
||||||
|
* Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий
|
||||||
|
* между MCP-серверами (например, оба могут иметь tool `search`).
|
||||||
|
*
|
||||||
|
* [describe] сериализует tool в JSON-дескриптор в формате, который litert-openai
|
||||||
|
* и litert-google принимают как function-calling definition:
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "type": "function",
|
||||||
|
* "function": {
|
||||||
|
* "name": "<server>__<tool>",
|
||||||
|
* "description": "...",
|
||||||
|
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* [invoke] вызывает [Client.callTool] по оригинальному (непрефиксованному) имени тула
|
||||||
|
* на нужном MCP-сервере и схлопывает [CallToolResult] в плоский текст:
|
||||||
|
* для каждого блока контента возвращается либо `text`, либо JSON-представление.
|
||||||
|
* Если `isError == true` — текст префиксуется `[tool error]`.
|
||||||
|
*/
|
||||||
|
internal class McpLiteToolAdapter(
|
||||||
|
private val serverName: String,
|
||||||
|
private val tool: Tool,
|
||||||
|
private val client: Client,
|
||||||
|
) : LiteTool {
|
||||||
|
|
||||||
|
internal val fullName: String = "${serverName}__${tool.name}"
|
||||||
|
|
||||||
|
override fun describe(): String =
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "function")
|
||||||
|
put("function", buildJsonObject {
|
||||||
|
put("name", fullName)
|
||||||
|
put("description", tool.description ?: "")
|
||||||
|
put("parameters", tool.inputSchema.toJsonSchema())
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
override fun invoke(arguments: String): String {
|
||||||
|
val argsMap = parseArgsJson(arguments, tool.name)
|
||||||
|
val result: CallToolResult = runBlocking { client.callTool(tool.name, argsMap) }
|
||||||
|
return renderResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderResult(result: CallToolResult): String {
|
||||||
|
val isError = result.isError == true
|
||||||
|
val parts = result.content.map { block ->
|
||||||
|
when (block) {
|
||||||
|
is TextContent -> block.text
|
||||||
|
else -> Json.encodeToString(JsonElement.serializer(), JsonPrimitive(block.toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val text = parts.joinToString("\n").ifEmpty { "[]" }
|
||||||
|
return if (isError) "[tool error] $text" else text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ToolSchema?.toJsonSchema(): JsonElement {
|
||||||
|
if (this == null) return buildJsonObject { put("type", "object") }
|
||||||
|
val props = this.properties ?: buildJsonObject { }
|
||||||
|
val reqs = this.required ?: emptyList()
|
||||||
|
return buildJsonObject {
|
||||||
|
put("type", this@toJsonSchema.type.ifEmpty { "object" })
|
||||||
|
put("properties", props)
|
||||||
|
put("required", JsonArray(reqs.map { JsonPrimitive(it) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
|
||||||
|
private fun parseArgsJson(raw: String, toolName: String): Map<String, Any?> {
|
||||||
|
if (raw.isBlank()) return emptyMap()
|
||||||
|
return try {
|
||||||
|
val parsed = json.parseToJsonElement(raw)
|
||||||
|
if (parsed !is JsonObject) emptyMap() else parsed.toAnyMap()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
System.err.println("[agentik] MCP tool '$toolName' got invalid args JSON: ${e.message}")
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.toAnyMap(): Map<String, Any?> =
|
||||||
|
entries.associate { (k, v) -> k to jsonElementToAny(v) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует [JsonPrimitive] в типизированное значение (Boolean/Int/Long/Float/Double),
|
||||||
|
* и только в крайнем случае — в String. Без этого MCP-сервер получает все аргументы
|
||||||
|
* как строки и отвергает их по JSON-schema (например, `max_length:500` → `"500"` →
|
||||||
|
* «'500' is not of type 'integer'»).
|
||||||
|
*/
|
||||||
|
private fun jsonElementToAny(el: JsonElement): Any? = when (el) {
|
||||||
|
is JsonPrimitive ->
|
||||||
|
el.booleanOrNull
|
||||||
|
?: el.intOrNull
|
||||||
|
?: el.longOrNull
|
||||||
|
?: el.doubleOrNull
|
||||||
|
?: if (el.isString) el.content else el.content
|
||||||
|
is JsonArray -> el.map { jsonElementToAny(it) }
|
||||||
|
is JsonObject -> el.toAnyMap()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence.sqlite
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import pw.binom.agentik.standalone.persistence.ConversationRecord
|
||||||
|
import pw.binom.agentik.standalone.persistence.ConversationStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-реализация [ConversationStore].
|
||||||
|
*/
|
||||||
|
class SqliteConversationStore(private val db: AgentikDatabase) : ConversationStore {
|
||||||
|
|
||||||
|
private val q get() = db.conversationQueries
|
||||||
|
|
||||||
|
override suspend fun upsert(record: ConversationRecord) {
|
||||||
|
// SQLite-конфликт по PRIMARY KEY → сначала пробуем insert, при ошибке → update
|
||||||
|
val existing = q.getById(record.id).executeAsOneOrNull()
|
||||||
|
if (existing == null) {
|
||||||
|
q.insert(
|
||||||
|
id = record.id,
|
||||||
|
title = record.title,
|
||||||
|
is_temporal = if (record.isTemporal) 1L else 0L,
|
||||||
|
created_at = record.createdAt.toEpochMilliseconds(),
|
||||||
|
updated_at = record.updatedAt.toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
q.update(
|
||||||
|
title = record.title,
|
||||||
|
is_temporal = if (record.isTemporal) 1L else 0L,
|
||||||
|
updated_at = record.updatedAt.toEpochMilliseconds(),
|
||||||
|
id = record.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(id: String): ConversationRecord? {
|
||||||
|
val row = q.getById(id).executeAsOneOrNull() ?: return null
|
||||||
|
return row.toRecord()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
// Проверяем существование ДО удаления — иначе пустой `deleteById` вернёт
|
||||||
|
// «успех», и get() == null будет true, хотя диалога и не было.
|
||||||
|
val existed = q.getById(id).executeAsOneOrNull() != null
|
||||||
|
if (!existed) return false
|
||||||
|
db.transaction {
|
||||||
|
db.messageQueries.deleteByConversation(id)
|
||||||
|
db.workingMemoryQueries.clearByConversation(id)
|
||||||
|
q.deleteById(id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> =
|
||||||
|
q.list(limit = limit.toLong(), offset = offset.toLong())
|
||||||
|
.executeAsList()
|
||||||
|
.map { it.toRecord() }
|
||||||
|
|
||||||
|
override suspend fun rename(id: String, title: String?): Instant? {
|
||||||
|
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||||
|
q.rename(title = title, updated_at = now.toEpochMilliseconds(), id = id)
|
||||||
|
val ts = q.getUpdatedAt(id).executeAsOneOrNull() ?: return null
|
||||||
|
return Instant.fromEpochMilliseconds(ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun touch(id: String, now: Instant) {
|
||||||
|
q.touch(updated_at = now.toEpochMilliseconds(), id = id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// driver закрывается во внешнем SqliteStores
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Conversation.toRecord(): ConversationRecord = ConversationRecord(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
isTemporal = is_temporal != 0L,
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(created_at),
|
||||||
|
updatedAt = Instant.fromEpochMilliseconds(updated_at),
|
||||||
|
)
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence.sqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.standalone.persistence.MessageRecord
|
||||||
|
import pw.binom.agentik.standalone.persistence.MessageStore
|
||||||
|
import pw.binom.agentik.standalone.persistence.decodeBodyPayload
|
||||||
|
import pw.binom.agentik.standalone.persistence.encodeBodyPayload
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-реализация [MessageStore] (append-only audit log).
|
||||||
|
*
|
||||||
|
* `payloadJson` хранит JSON-сериализованные kind-specific поля. Для
|
||||||
|
* `user`/`assistant` это `List<Content>` (см. [encodeBodyPayload]).
|
||||||
|
* Для `tool_call`/`tool_result` payload хранит JSON-объект
|
||||||
|
* (см. [CallPayload]/[ResultPayload]).
|
||||||
|
*
|
||||||
|
* SQLDelight сохраняет snake_case в сгенерированной data class (`Message`),
|
||||||
|
* поэтому обращаемся через `conversation_id`, `payload_json`, `created_at`.
|
||||||
|
*/
|
||||||
|
class SqliteMessageStore(private val db: AgentikDatabase) : MessageStore {
|
||||||
|
|
||||||
|
private val q get() = db.messageQueries
|
||||||
|
|
||||||
|
override suspend fun append(record: MessageRecord) {
|
||||||
|
val (kind, payload) = encodeRecord(record)
|
||||||
|
q.insert(
|
||||||
|
conversation_id = record.conversationId,
|
||||||
|
kind = kind,
|
||||||
|
payload_json = payload,
|
||||||
|
created_at = record.createdAt.toEpochMilliseconds(),
|
||||||
|
id = record.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> = q.listAfter(
|
||||||
|
conversation_id = conversationId,
|
||||||
|
created_at = after.toEpochMilliseconds(),
|
||||||
|
limit = limit.toLong(),
|
||||||
|
offset = offset.toLong(),
|
||||||
|
).executeAsList().map { it.toRecord() }
|
||||||
|
|
||||||
|
override suspend fun listAll(conversationId: String): List<MessageRecord> =
|
||||||
|
q.listByConversationAll(conversation_id = conversationId).executeAsList().map { it.toRecord() }
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
||||||
|
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(record.content)
|
||||||
|
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(record.content)
|
||||||
|
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
||||||
|
CallPayload.serializer(),
|
||||||
|
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
|
||||||
|
ResultPayload.serializer(),
|
||||||
|
ResultPayload(toolCallId = record.toolCallId, result = record.result),
|
||||||
|
)
|
||||||
|
is MessageRecord.Summary,
|
||||||
|
is MessageRecord.System -> error("Summary/System — synthetic, cannot append to audit log")
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class ResultPayload(val toolCallId: String, val result: String?)
|
||||||
|
|
||||||
|
private fun Message.toRecord(): MessageRecord {
|
||||||
|
val id = id
|
||||||
|
val convId = conversation_id
|
||||||
|
val createdAt = Instant.fromEpochMilliseconds(created_at)
|
||||||
|
return when (kind) {
|
||||||
|
"user" -> MessageRecord.UserMessage(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
content = decodeBodyPayload(payload_json),
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
"assistant" -> MessageRecord.AssistantMessage(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
content = decodeBodyPayload(payload_json),
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
"tool_call" -> {
|
||||||
|
val p = Json.decodeFromString(CallPayload.serializer(), payload_json)
|
||||||
|
MessageRecord.ToolCall(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
toolName = p.name,
|
||||||
|
toolTitle = p.title,
|
||||||
|
toolArgsJson = p.argsJson,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"tool_result" -> {
|
||||||
|
val p = Json.decodeFromString(ResultPayload.serializer(), payload_json)
|
||||||
|
MessageRecord.ToolResult(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
toolCallId = p.toolCallId,
|
||||||
|
result = p.result,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> error("Unknown message kind in audit log: $kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence.sqlite
|
||||||
|
|
||||||
|
import app.cash.sqldelight.db.QueryResult
|
||||||
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||||
|
import pw.binom.agentik.standalone.persistence.ConversationStore
|
||||||
|
import pw.binom.agentik.standalone.persistence.MessageStore
|
||||||
|
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневой объект SQLite-слоя: держит [SqlDriver] и три [WorkingMemoryStore]/[MessageStore]/[ConversationStore].
|
||||||
|
* Закрывается вместе с приложением.
|
||||||
|
*/
|
||||||
|
class SqliteStores private constructor(
|
||||||
|
val driver: SqlDriver,
|
||||||
|
val conversations: ConversationStore,
|
||||||
|
val messages: MessageStore,
|
||||||
|
val workingMemory: WorkingMemoryStore,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
conversations.close()
|
||||||
|
messages.close()
|
||||||
|
workingMemory.close()
|
||||||
|
driver.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/** Открыть/создать БД по пути `dbPath` (например, `"./agentik.db"` или абсолютный путь). */
|
||||||
|
fun open(dbPath: String): SqliteStores {
|
||||||
|
val driver = JdbcSqliteDriver("jdbc:sqlite:$dbPath")
|
||||||
|
createSchema(driver)
|
||||||
|
val db = AgentikDatabase(driver)
|
||||||
|
return SqliteStores(
|
||||||
|
driver = driver,
|
||||||
|
conversations = SqliteConversationStore(db),
|
||||||
|
messages = SqliteMessageStore(db),
|
||||||
|
workingMemory = SqliteWorkingMemoryStore(db),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Открыть/создать БД в памяти (для тестов). */
|
||||||
|
fun inMemory(): SqliteStores {
|
||||||
|
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
||||||
|
createSchema(driver)
|
||||||
|
val db = AgentikDatabase(driver)
|
||||||
|
return SqliteStores(
|
||||||
|
driver = driver,
|
||||||
|
conversations = SqliteConversationStore(db),
|
||||||
|
messages = SqliteMessageStore(db),
|
||||||
|
workingMemory = SqliteWorkingMemoryStore(db),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSchema(driver: SqlDriver) {
|
||||||
|
// Если таблица `conversation` уже есть — БД уже инициализирована,
|
||||||
|
// просто пропускаем create (иначе CREATE TABLE упадёт на дубликате).
|
||||||
|
val existing = driver.executeQuery(
|
||||||
|
identifier = null,
|
||||||
|
sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation'",
|
||||||
|
mapper = { cursor ->
|
||||||
|
QueryResult.Value(
|
||||||
|
if (cursor.next().value) cursor.getString(0) else null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
parameters = 0,
|
||||||
|
).value
|
||||||
|
if (existing != null) return
|
||||||
|
AgentikDatabase.Schema.create(driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence.sqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.standalone.persistence.WorkingMemoryRow
|
||||||
|
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-реализация [WorkingMemoryStore] (мутируемый LLM-контекст).
|
||||||
|
*
|
||||||
|
* Строки упорядочены по `order_idx ASC`. Compact — атомарная операция:
|
||||||
|
* DELETE строк `>= dropFromOrderIdx` (без summary в v1).
|
||||||
|
*/
|
||||||
|
class SqliteWorkingMemoryStore(private val db: AgentikDatabase) : WorkingMemoryStore {
|
||||||
|
|
||||||
|
private val q get() = db.workingMemoryQueries
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) {
|
||||||
|
val newIdx = (q.maxOrderIdx(conversationId).executeAsOne()) + 1
|
||||||
|
q.insert(
|
||||||
|
id = newId(),
|
||||||
|
conversation_id = conversationId,
|
||||||
|
order_idx = newIdx,
|
||||||
|
source_message_id = entry.sourceMessageId,
|
||||||
|
kind = entryKind(entry),
|
||||||
|
payload_json = json.encodeToString(WorkingMemoryEntry.serializer(), entry),
|
||||||
|
created_at = now.toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(conversationId: String): List<WorkingMemoryRow> =
|
||||||
|
q.listByConversation(conversationId).executeAsList().map { it.toRow() }
|
||||||
|
|
||||||
|
override suspend fun clear(conversationId: String) {
|
||||||
|
q.clearByConversation(conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long {
|
||||||
|
var newMax = 0L
|
||||||
|
db.transaction {
|
||||||
|
q.compactDelete(conversation_id = conversationId, order_idx = dropFromOrderIdx)
|
||||||
|
newMax = q.maxOrderIdx(conversationId).executeAsOne()
|
||||||
|
}
|
||||||
|
return newMax
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
|
||||||
|
is WorkingMemoryEntry.System -> "system"
|
||||||
|
is WorkingMemoryEntry.User -> "user"
|
||||||
|
is WorkingMemoryEntry.Assistant -> "assistant"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Working_memory.toRow(): WorkingMemoryRow {
|
||||||
|
val entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), payload_json)
|
||||||
|
return WorkingMemoryRow(
|
||||||
|
id = id,
|
||||||
|
conversationId = conversation_id,
|
||||||
|
orderIdx = order_idx,
|
||||||
|
sourceMessageId = source_message_id,
|
||||||
|
entry = entry,
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(created_at),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newId(): String = "wm-${java.util.UUID.randomUUID()}"
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
CREATE TABLE conversation (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
is_temporal INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_conv_updated ON conversation(updated_at DESC);
|
||||||
|
|
||||||
|
insert:
|
||||||
|
INSERT INTO conversation (id, title, is_temporal, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?);
|
||||||
|
|
||||||
|
update:
|
||||||
|
UPDATE conversation SET title = ?, is_temporal = ?, updated_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
getById:
|
||||||
|
SELECT * FROM conversation WHERE id = ?;
|
||||||
|
|
||||||
|
list:
|
||||||
|
SELECT * FROM conversation
|
||||||
|
WHERE is_temporal = 0
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
listAll:
|
||||||
|
SELECT * FROM conversation
|
||||||
|
ORDER BY updated_at DESC;
|
||||||
|
|
||||||
|
deleteById:
|
||||||
|
DELETE FROM conversation WHERE id = ?;
|
||||||
|
|
||||||
|
rename:
|
||||||
|
UPDATE conversation SET title = ?, updated_at = ? WHERE id = ?;
|
||||||
|
|
||||||
|
getUpdatedAt:
|
||||||
|
SELECT updated_at FROM conversation WHERE id = ?;
|
||||||
|
|
||||||
|
touch:
|
||||||
|
UPDATE conversation SET updated_at = ? WHERE id = ?;
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE message (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_msg_conv ON message(conversation_id, created_at);
|
||||||
|
|
||||||
|
insert:
|
||||||
|
INSERT INTO message (id, conversation_id, kind, payload_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?);
|
||||||
|
|
||||||
|
listByConversation:
|
||||||
|
SELECT * FROM message
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
listByConversationAll:
|
||||||
|
SELECT * FROM message
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
ORDER BY created_at ASC, id ASC;
|
||||||
|
|
||||||
|
listAfter:
|
||||||
|
SELECT * FROM message
|
||||||
|
WHERE conversation_id = ? AND created_at > ?
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
deleteByConversation:
|
||||||
|
DELETE FROM message WHERE conversation_id = ?;
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE working_memory (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
order_idx INTEGER NOT NULL,
|
||||||
|
source_message_id TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_wm_unique ON working_memory(conversation_id, order_idx);
|
||||||
|
CREATE INDEX idx_wm_conv ON working_memory(conversation_id, order_idx);
|
||||||
|
|
||||||
|
insert:
|
||||||
|
INSERT INTO working_memory (id, conversation_id, order_idx, source_message_id, kind, payload_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||||
|
|
||||||
|
listByConversation:
|
||||||
|
SELECT * FROM working_memory
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
ORDER BY order_idx ASC;
|
||||||
|
|
||||||
|
clearByConversation:
|
||||||
|
DELETE FROM working_memory WHERE conversation_id = ?;
|
||||||
|
|
||||||
|
maxOrderIdx:
|
||||||
|
SELECT COALESCE(MAX(order_idx), 0) FROM working_memory WHERE conversation_id = ?;
|
||||||
|
|
||||||
|
-- Atomic compact: delete rows >= dropFromOrderIdx and insert summary.
|
||||||
|
-- Caller supplies summaryId, summaryText, now epoch millis, and the new summary
|
||||||
|
-- gets order_idx = current max (after delete = before max).
|
||||||
|
compactDelete:
|
||||||
|
DELETE FROM working_memory
|
||||||
|
WHERE conversation_id = ? AND order_idx >= ?;
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.proto.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event as ProtoEvent
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||||
|
import pw.binom.litert.LiteContentPart
|
||||||
|
import pw.binom.litert.LiteConversation
|
||||||
|
import pw.binom.litert.LiteConversationConfig
|
||||||
|
import pw.binom.litert.LiteDelta
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.LiteMessage
|
||||||
|
import pw.binom.litert.LiteRole
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import pw.binom.litert.LiteToolCall
|
||||||
|
import pw.binom.litert.openai.OpenAiConfig
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ChatAgentTest {
|
||||||
|
|
||||||
|
private lateinit var stores: SqliteStores
|
||||||
|
private lateinit var fakeLlm: FakeLiteLlm
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
stores = SqliteStores.inMemory()
|
||||||
|
fakeLlm = FakeLiteLlm()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
stores.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newAgent(
|
||||||
|
stores: SqliteStores = this.stores,
|
||||||
|
llm: LiteLlm = this.fakeLlm,
|
||||||
|
tools: List<NamedTool> = emptyList(),
|
||||||
|
): ChatAgent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = llm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
tools = tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createConversation seeds system prompt into working memory`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
|
val wm = stores.workingMemory.list(conv.id)
|
||||||
|
assertEquals(1, wm.size)
|
||||||
|
val first = wm[0]
|
||||||
|
assertIs<pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.System>(first.entry)
|
||||||
|
assertEquals("be brief", first.entry.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getConversation returns null for unknown id`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
assertNull(agent.getConversation("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getConversations returns all stored persistent conversations`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
agent.createConversation(temp = false)
|
||||||
|
agent.createConversation(temp = true)
|
||||||
|
val list = agent.getConversations(0, 10)
|
||||||
|
// temp-беседы не персистятся, в списке только persistent
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteConversation removes conversation and data`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
val id = conv.id
|
||||||
|
|
||||||
|
// добавим сообщение, чтобы потом убедиться, что каскад сработал
|
||||||
|
stores.messages.append(
|
||||||
|
pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = id,
|
||||||
|
content = listOf(pw.binom.agentik.standalone.persistence.Content.Text("hi")),
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(agent.deleteConversation(id))
|
||||||
|
assertNull(agent.getConversation(id))
|
||||||
|
assertNull(stores.conversations.get(id))
|
||||||
|
assertEquals(emptyList(), stores.messages.listAll(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteConversation returns false for unknown id`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
assertEquals(false, agent.deleteConversation("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send emits start_reasoning, start_response, append_text, end`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.reply = "hello back"
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
|
||||||
|
// user message записан в audit + working memory
|
||||||
|
val msgs = stores.messages.listAll(conv.id)
|
||||||
|
assertEquals(2, msgs.size)
|
||||||
|
assertEquals("hi", (msgs[0] as pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage).content.let {
|
||||||
|
(it[0] as pw.binom.agentik.standalone.persistence.Content.Text).body
|
||||||
|
})
|
||||||
|
assertEquals("hello back", (msgs[1] as pw.binom.agentik.standalone.persistence.MessageRecord.AssistantMessage).content.let {
|
||||||
|
(it[0] as pw.binom.agentik.standalone.persistence.Content.Text).body
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send reconstructs conversation history from working memory`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
fakeLlm.reply = "first reply"
|
||||||
|
val conv1 = agent.createConversation(temp = false)
|
||||||
|
conv1.send(listOf(Content.Text("first user")))
|
||||||
|
|
||||||
|
// Новая беседа не должна видеть историю первой
|
||||||
|
val conv2 = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "second reply"
|
||||||
|
conv2.send(listOf(Content.Text("second user")))
|
||||||
|
|
||||||
|
// Первая беседа должна иметь только свою систему + 1 user + 1 assistant
|
||||||
|
val wm1 = stores.workingMemory.list(conv1.id)
|
||||||
|
assertEquals(3, wm1.size)
|
||||||
|
// Вторая беседа — только своё
|
||||||
|
val wm2 = stores.workingMemory.list(conv2.id)
|
||||||
|
assertEquals(3, wm2.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send passes system prompt and past history to LLM on first send`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "hi"
|
||||||
|
conv.send(listOf(Content.Text("hello")))
|
||||||
|
|
||||||
|
// Длинно-живущий LiteConversation: первый send создаёт его с systemInstruction
|
||||||
|
// и пустыми initialMessages (свежее user-сообщение пойдёт через sendStreamContents).
|
||||||
|
assertNotNull(fakeLlm.lastConfig)
|
||||||
|
assertEquals("be brief", fakeLlm.lastConfig!!.systemInstruction)
|
||||||
|
assertEquals(0, fakeLlm.lastConfig!!.initialMessages.size)
|
||||||
|
// Свежее user-сообщение отправлено через sendStreamContents
|
||||||
|
assertEquals(1, fakeLlm.conversations.size)
|
||||||
|
val sent = fakeLlm.lastContents
|
||||||
|
assertNotNull(sent)
|
||||||
|
assertEquals(1, sent!!.size)
|
||||||
|
assertEquals("hello", (sent[0] as LiteContentPart.Text).text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-turn conversation accumulates in single LiteConversation`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
fakeLlm.reply = "first reply"
|
||||||
|
conv.send(listOf(Content.Text("first user")))
|
||||||
|
// первый turn: WM = [system, user, assistant]
|
||||||
|
assertEquals(3, stores.workingMemory.list(conv.id).size)
|
||||||
|
|
||||||
|
fakeLlm.reply = "second reply"
|
||||||
|
conv.send(listOf(Content.Text("second user")))
|
||||||
|
// второй turn: WM должен вырасти до [system, user, assistant, user, assistant]
|
||||||
|
val wm = stores.workingMemory.list(conv.id)
|
||||||
|
assertEquals(5, wm.size)
|
||||||
|
// Длинно-живущий LiteConversation: один на ChatConversation, история
|
||||||
|
// накапливается через sendStreamContents, без пересоздания.
|
||||||
|
assertEquals(1, fakeLlm.conversations.size)
|
||||||
|
val history = fakeLlm.conversations[0].history
|
||||||
|
assertEquals(4, history.size)
|
||||||
|
assertEquals("first user", history[0].text)
|
||||||
|
assertEquals(LiteRole.USER, history[0].role)
|
||||||
|
assertEquals("first reply", history[1].text)
|
||||||
|
assertEquals(LiteRole.MODEL, history[1].role)
|
||||||
|
assertEquals("second user", history[2].text)
|
||||||
|
assertEquals(LiteRole.USER, history[2].role)
|
||||||
|
assertEquals("second reply", history[3].text)
|
||||||
|
assertEquals(LiteRole.MODEL, history[3].role)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reloaded conversation reconstructs LiteConversation from working memory`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "first reply"
|
||||||
|
conv.send(listOf(Content.Text("first user")))
|
||||||
|
val convId = conv.id
|
||||||
|
conv.close()
|
||||||
|
|
||||||
|
// Открываем новое ChatConversation с тем же id — LiteConversation должен
|
||||||
|
// быть создан заново из working memory (первый user+assistant как initial).
|
||||||
|
val reopened = agent.getConversation(convId)!!
|
||||||
|
fakeLlm.reply = "second reply"
|
||||||
|
reopened.send(listOf(Content.Text("second user")))
|
||||||
|
|
||||||
|
val allConvs = fakeLlm.conversations
|
||||||
|
assertEquals(2, allConvs.size) // original + reopened
|
||||||
|
val reopenedLite = allConvs.last()
|
||||||
|
// Initial messages: только прошлые user+assistant (НЕ включая текущий "second user")
|
||||||
|
assertEquals(2, reopenedLite.initialMessages.size)
|
||||||
|
assertEquals("first user", reopenedLite.initialMessages[0].text)
|
||||||
|
assertEquals(LiteRole.USER, reopenedLite.initialMessages[0].role)
|
||||||
|
assertEquals("first reply", reopenedLite.initialMessages[1].text)
|
||||||
|
assertEquals(LiteRole.MODEL, reopenedLite.initialMessages[1].role)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupt cancels active send`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.slow = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
val sendJob = launch {
|
||||||
|
try {
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
|
// ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ждём, пока корутина дойдёт до sendStreamContents и повиснет на slow-эмиссии
|
||||||
|
delay(200)
|
||||||
|
conv.interrupt()
|
||||||
|
sendJob.join()
|
||||||
|
|
||||||
|
// user сообщение в audit должно быть, assistant — нет (был отменён)
|
||||||
|
val msgs = stores.messages.listAll(conv.id)
|
||||||
|
assertEquals(1, msgs.size)
|
||||||
|
assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage>(msgs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `temp conversation is not persisted across agent instances`() = runTest {
|
||||||
|
// Поднимаем file-backed БД, создаём temp-беседу
|
||||||
|
stores.close()
|
||||||
|
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
||||||
|
stores = SqliteStores.open(dbPath)
|
||||||
|
val agent1 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = FakeLiteLlm().also { fakeLlm = it },
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val tempConv = agent1.createConversation(temp = true)
|
||||||
|
val tempId = tempConv.id
|
||||||
|
assertNotNull(agent1.getConversation(tempId))
|
||||||
|
|
||||||
|
// Переоткрываем БД — temp-беседа не должна пережить рестарт
|
||||||
|
stores.close()
|
||||||
|
stores = SqliteStores.open(dbPath)
|
||||||
|
val agent2 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNull(agent2.getConversation(tempId))
|
||||||
|
java.io.File(dbPath).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-temp conversation persists across agent instances`() = runTest {
|
||||||
|
stores.close()
|
||||||
|
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
||||||
|
stores = SqliteStores.open(dbPath)
|
||||||
|
val agent1 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = FakeLiteLlm().also { fakeLlm = it },
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val conv = agent1.createConversation(temp = false)
|
||||||
|
val id = conv.id
|
||||||
|
|
||||||
|
stores.close()
|
||||||
|
stores = SqliteStores.open(dbPath)
|
||||||
|
val agent2 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNotNull(agent2.getConversation(id))
|
||||||
|
java.io.File(dbPath).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm {
|
||||||
|
override val backendName: String = "fake"
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
override fun createConversation(config: LiteConversationConfig): LiteConversation =
|
||||||
|
error("not used in reload test")
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used")
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> = error("not used")
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tool-call loop executes registered tool and feeds result back`() = runTest {
|
||||||
|
val echoTool = object : LiteTool {
|
||||||
|
override fun describe(): String = """{"type":"function","function":{"name":"echo"}}"""
|
||||||
|
override fun invoke(arguments: String): String = "echoed: $arguments"
|
||||||
|
}
|
||||||
|
val toolLlm = ToolLoopFakeLiteLlm()
|
||||||
|
val agent = newAgent(llm = toolLlm, tools = listOf(NamedTool("echo", echoTool)))
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
||||||
|
|
||||||
|
assertEquals(1, toolLlm.toolCallCount, "expected one round-trip through LiteConversation")
|
||||||
|
assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult,
|
||||||
|
"expected echo tool invoked with the LLM's args, result fed back via addToolResult")
|
||||||
|
assertEquals("final reply", toolLlm.finalReplyEmitted,
|
||||||
|
"expected continuation send after tool result to emit final text")
|
||||||
|
|
||||||
|
agent.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `agentEvents — Created + Deleted flow`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
val events = mutableListOf<AgentEvent>()
|
||||||
|
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.events(Instant.DISTANT_PAST).collect { events.add(it) }
|
||||||
|
}
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
agent.deleteConversation(conv.id)
|
||||||
|
delay(50)
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
assertEquals(2, events.size)
|
||||||
|
val created = events[0] as AgentEvent.Created
|
||||||
|
val deleted = events[1] as AgentEvent.Deleted
|
||||||
|
assertEquals(conv.id, created.conversationId)
|
||||||
|
assertEquals(conv.id, deleted.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */
|
||||||
|
private class FakeLiteLlm : LiteLlm {
|
||||||
|
override val backendName: String = "fake"
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
||||||
|
var reply: String = ""
|
||||||
|
var rememberHistory: Boolean = false
|
||||||
|
var slow: Boolean = false
|
||||||
|
var lastConfig: LiteConversationConfig? = null
|
||||||
|
var lastContents: List<LiteContentPart>? = null
|
||||||
|
val conversations = mutableListOf<FakeLiteConversation>()
|
||||||
|
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
|
||||||
|
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
||||||
|
lastConfig = config
|
||||||
|
val conv = FakeLiteConversation(this, config)
|
||||||
|
conversations.add(conv)
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String {
|
||||||
|
throw UnsupportedOperationException("not used in test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> {
|
||||||
|
throw UnsupportedOperationException("not used in test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
|
||||||
|
fun emit(text: String, sink: FakeLiteConversation): List<LiteDelta> {
|
||||||
|
// Эмулируем один-два фрагмента + done
|
||||||
|
return listOf(
|
||||||
|
LiteDelta(text = text.substring(0, text.length / 2), isDone = false),
|
||||||
|
LiteDelta(text = text.substring(text.length / 2), isDone = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLiteConversation(
|
||||||
|
private val parent: FakeLiteLlm,
|
||||||
|
config: LiteConversationConfig,
|
||||||
|
) : LiteConversation {
|
||||||
|
val initialMessages: List<LiteMessage> = config.initialMessages
|
||||||
|
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
||||||
|
override val history: List<LiteMessage>
|
||||||
|
get() = mutableHistory.toList()
|
||||||
|
|
||||||
|
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
||||||
|
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||||
|
|
||||||
|
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||||
|
parent.lastContents = contents
|
||||||
|
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
||||||
|
if (parent.slow) {
|
||||||
|
return kotlinx.coroutines.flow.flow {
|
||||||
|
emit(LiteDelta(text = parent.reply.substring(0, parent.reply.length / 2)))
|
||||||
|
kotlinx.coroutines.delay(10_000)
|
||||||
|
emit(LiteDelta(text = parent.reply.substring(parent.reply.length / 2), isDone = true))
|
||||||
|
mutableHistory.add(LiteMessage.model(parent.reply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val first = parent.reply.substring(0, parent.reply.length / 2)
|
||||||
|
val second = parent.reply.substring(parent.reply.length / 2)
|
||||||
|
return flowOf(
|
||||||
|
LiteDelta(text = first),
|
||||||
|
LiteDelta(text = second, isDone = true),
|
||||||
|
).also {
|
||||||
|
mutableHistory.add(LiteMessage.model(parent.reply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun send(prompt: String): String = parent.reply
|
||||||
|
override fun sendContents(contents: List<LiteContentPart>): String = parent.reply
|
||||||
|
override fun cancel() {}
|
||||||
|
override fun tokenCount(): Int = history.size
|
||||||
|
override fun addToolResult(callId: String?, name: String, result: String) { error("not used") }
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LiteLlm который имитирует tool-loop:
|
||||||
|
* - первый send → LiteDelta(toolCalls=[LiteToolCall("echo", {"x":"hi"})], isDone=true)
|
||||||
|
* - после addToolResult → продолжение send отдаёт LiteDelta(text="final reply", isDone=true)
|
||||||
|
*/
|
||||||
|
private class ToolLoopFakeLiteLlm : LiteLlm {
|
||||||
|
override val backendName: String = "fake-tool"
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
||||||
|
|
||||||
|
var toolCallCount: Int = 0
|
||||||
|
var lastToolResult: String? = null
|
||||||
|
var finalReplyEmitted: String? = null
|
||||||
|
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
|
||||||
|
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
||||||
|
return object : LiteConversation {
|
||||||
|
private val hist = mutableListOf<LiteMessage>()
|
||||||
|
override val history: List<LiteMessage> get() = hist.toList()
|
||||||
|
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||||
|
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||||
|
hist.add(LiteMessage(LiteRole.USER, contents))
|
||||||
|
return if (toolCallCount == 0) {
|
||||||
|
toolCallCount++
|
||||||
|
flowOf(
|
||||||
|
LiteDelta(
|
||||||
|
text = "",
|
||||||
|
isDone = true,
|
||||||
|
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val reply = "final reply"
|
||||||
|
finalReplyEmitted = reply
|
||||||
|
flowOf(LiteDelta(text = reply, isDone = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun send(prompt: String): String = "unused"
|
||||||
|
override fun sendContents(contents: List<LiteContentPart>): String = "unused"
|
||||||
|
override fun cancel() {}
|
||||||
|
override fun tokenCount(): Int = hist.size
|
||||||
|
override fun addToolResult(callId: String?, name: String, result: String) {
|
||||||
|
System.err.println("[agentik] DEBUG ToolLoopFakeLiteLlm.addToolResult callId=$callId name=$name result=$result")
|
||||||
|
lastToolResult = result
|
||||||
|
}
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used")
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> = error("not used")
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package pw.binom.agentik.standalone.llm
|
||||||
|
|
||||||
|
import pw.binom.litert.openai.OpenAiConfig
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
|
class LlmConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv — happy path`() {
|
||||||
|
val cfg = LlmConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
"AGENTIK_SYSTEM_PROMPT" -> "be brief"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("be brief", cfg.systemPrompt)
|
||||||
|
assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv — falls back to default system prompt`() {
|
||||||
|
val cfg = LlmConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv — missing base url throws`() {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
LlmConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv — missing api key throws`() {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
LlmConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv — missing model throws`() {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
LlmConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank system prompt from env falls back to default`() {
|
||||||
|
val cfg = LlmConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
"AGENTIK_SYSTEM_PROMPT" -> " "
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package pw.binom.agentik.standalone.mcp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class McpConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv returns empty when env var unset`() {
|
||||||
|
val cfg = McpConfig.fromEnv { null }
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
assertEquals(emptyList(), cfg.servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv returns empty when env var blank`() {
|
||||||
|
val cfg = McpConfig.fromEnv { "" }
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv returns empty when file missing`() {
|
||||||
|
val cfg = McpConfig.fromEnv { "/tmp/agentik-nonexistent-mcp-${System.nanoTime()}.json" }
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson parses stdio server`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"fs": { "command": "npx", "args": ["-y", "fs-mcp"], "env": { "ROOT": "/work" } }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
assertEquals(1, cfg.servers.size)
|
||||||
|
val s = cfg.servers.single() as McpServerSpec.Stdio
|
||||||
|
assertEquals("fs", s.name)
|
||||||
|
assertEquals("npx", s.command)
|
||||||
|
assertEquals(listOf("-y", "fs-mcp"), s.args)
|
||||||
|
assertEquals(mapOf("ROOT" to "/work"), s.env)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson parses http server with headers`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer X" } }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
val s = cfg.servers.single() as McpServerSpec.Http
|
||||||
|
assertEquals("remote", s.name)
|
||||||
|
assertEquals("https://example.com/mcp", s.url)
|
||||||
|
assertEquals(mapOf("Authorization" to "Bearer X"), s.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson parses both stdio and http together`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"fs": { "command": "npx", "args": [] },
|
||||||
|
"remote":{ "url": "https://example.com/mcp" }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
assertEquals(2, cfg.servers.size)
|
||||||
|
assertTrue(cfg.servers.any { it is McpServerSpec.Stdio && it.name == "fs" })
|
||||||
|
assertTrue(cfg.servers.any { it is McpServerSpec.Http && it.name == "remote" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson skips entries without command or url`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"broken": { "description": "no transport" }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty returns empty config`() {
|
||||||
|
val cfg = McpConfig.empty()
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package pw.binom.agentik.standalone.mcp
|
||||||
|
|
||||||
|
import pw.binom.agentik.standalone.agent.NamedTool
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class McpRegistryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty config produces empty registry`() {
|
||||||
|
val registry = McpRegistry.fromConfig(McpConfig.empty())
|
||||||
|
assertEquals(0, registry.allTools.size)
|
||||||
|
assertEquals(0, registry.connectedServerCount)
|
||||||
|
assertEquals(emptyList(), registry.namedTools)
|
||||||
|
registry.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty servers list produces empty registry`() {
|
||||||
|
val registry = McpRegistry(servers = emptyList())
|
||||||
|
assertEquals(0, registry.namedTools.size)
|
||||||
|
assertEquals(0, registry.allTools.size)
|
||||||
|
registry.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NamedTool holds name and tool reference`() {
|
||||||
|
val noop: LiteTool = object : LiteTool {
|
||||||
|
override fun describe(): String = "{}"
|
||||||
|
override fun invoke(arguments: String): String = ""
|
||||||
|
}
|
||||||
|
val nt = NamedTool(name = "server__echo", tool = noop)
|
||||||
|
assertEquals("server__echo", nt.name)
|
||||||
|
assertSame(noop, nt.tool)
|
||||||
|
assertEquals("{}", nt.tool.describe())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `registry close is idempotent`() {
|
||||||
|
val registry = McpRegistry.fromConfig(McpConfig.empty())
|
||||||
|
registry.close()
|
||||||
|
registry.close() // should not throw
|
||||||
|
assertTrue(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class PersistenceTest {
|
||||||
|
|
||||||
|
private lateinit var stores: SqliteStores
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
stores = SqliteStores.inMemory()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
stores.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `upsert + get conversation — roundtrip`() = runTest {
|
||||||
|
val now = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val rec = ConversationRecord(
|
||||||
|
id = "c1",
|
||||||
|
title = "Hello",
|
||||||
|
isTemporal = false,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
stores.conversations.upsert(rec)
|
||||||
|
val got = stores.conversations.get("c1")
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals(rec.id, got.id)
|
||||||
|
assertEquals(rec.title, got.title)
|
||||||
|
assertEquals(rec.isTemporal, got.isTemporal)
|
||||||
|
assertEquals(rec.createdAt, got.createdAt)
|
||||||
|
assertEquals(rec.updatedAt, got.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `upsert overwrites existing record`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord("c1", title = "A", isTemporal = false, createdAt = t0, updatedAt = t0),
|
||||||
|
)
|
||||||
|
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord("c1", title = "B", isTemporal = true, createdAt = t0, updatedAt = t1),
|
||||||
|
)
|
||||||
|
val got = stores.conversations.get("c1")!!
|
||||||
|
assertEquals("B", got.title)
|
||||||
|
assertTrue(got.isTemporal)
|
||||||
|
assertEquals(t1, got.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list returns conversations ordered by updated_at desc`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
repeat(3) { i ->
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord(
|
||||||
|
id = "c$i",
|
||||||
|
title = null,
|
||||||
|
isTemporal = false,
|
||||||
|
createdAt = t0,
|
||||||
|
updatedAt = Instant.fromEpochMilliseconds(1_700_000_000_000 + i * 1000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val list = stores.conversations.list(offset = 0, limit = 10)
|
||||||
|
assertEquals(listOf("c2", "c1", "c0"), list.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete cascades messages and working_memory`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord("c1", null, false, t0, t0),
|
||||||
|
)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "c1",
|
||||||
|
content = listOf(Content.Text("hello")),
|
||||||
|
createdAt = t0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.User(
|
||||||
|
sourceMessageId = "m1",
|
||||||
|
content = listOf(Content.Text("hello")),
|
||||||
|
),
|
||||||
|
now = t0,
|
||||||
|
)
|
||||||
|
assertEquals(1, stores.messages.listAll("c1").size)
|
||||||
|
assertEquals(1, stores.workingMemory.list("c1").size)
|
||||||
|
|
||||||
|
val removed = stores.conversations.delete("c1")
|
||||||
|
assertTrue(removed)
|
||||||
|
assertNull(stores.conversations.get("c1"))
|
||||||
|
assertEquals(emptyList(), stores.messages.listAll("c1"))
|
||||||
|
assertEquals(emptyList(), stores.workingMemory.list("c1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `message audit log — append and read back`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0),
|
||||||
|
)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val all = stores.messages.listAll("c1")
|
||||||
|
assertEquals(2, all.size)
|
||||||
|
assertEquals("m1", all[0].id)
|
||||||
|
assertEquals("m2", all[1].id)
|
||||||
|
assertTrue(all[0] is MessageRecord.UserMessage)
|
||||||
|
assertTrue(all[1] is MessageRecord.AssistantMessage)
|
||||||
|
assertEquals("hi", (all[0] as MessageRecord.UserMessage).content[0].let {
|
||||||
|
(it as Content.Text).body
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `message after timestamp filter`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||||
|
stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0))
|
||||||
|
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1))
|
||||||
|
|
||||||
|
val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10)
|
||||||
|
assertEquals(1, after.size)
|
||||||
|
assertEquals("m2", after[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working memory — append + list preserves order`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.System(text = "you are a bot"),
|
||||||
|
now = t0,
|
||||||
|
)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))),
|
||||||
|
now = t0,
|
||||||
|
)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.Assistant(sourceMessageId = "m2", content = listOf(Content.Text("yo"))),
|
||||||
|
now = t1,
|
||||||
|
)
|
||||||
|
val list = stores.workingMemory.list("c1")
|
||||||
|
assertEquals(3, list.size)
|
||||||
|
assertTrue(list[0].entry is WorkingMemoryEntry.System)
|
||||||
|
assertTrue(list[1].entry is WorkingMemoryEntry.User)
|
||||||
|
assertTrue(list[2].entry is WorkingMemoryEntry.Assistant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename updates title and bumps updated_at`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||||
|
val newTs = stores.conversations.rename("c1", "Renamed")
|
||||||
|
assertNotNull(newTs)
|
||||||
|
assertTrue(newTs > t0)
|
||||||
|
val got = stores.conversations.get("c1")!!
|
||||||
|
assertEquals("Renamed", got.title)
|
||||||
|
assertEquals(newTs, got.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename can clear title`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(ConversationRecord("c1", "Title", false, t0, t0))
|
||||||
|
stores.conversations.rename("c1", null)
|
||||||
|
val got = stores.conversations.get("c1")!!
|
||||||
|
assertNull(got.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete returns false when conversation does not exist`() = runTest {
|
||||||
|
assertFalse(stores.conversations.delete("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `image content roundtrip through message payload`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val bytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "c1",
|
||||||
|
content = listOf(Content.Image(data = bytes, mime = "image/png")),
|
||||||
|
createdAt = t0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val all = stores.messages.listAll("c1")
|
||||||
|
val image = (all[0] as MessageRecord.UserMessage).content[0] as Content.Image
|
||||||
|
assertEquals("image/png", image.mime)
|
||||||
|
assertTrue(bytes.contentEquals(image.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user