diff --git a/.gitignore b/.gitignore index 460fb1d..b1f211a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ agentik.db agentik.db-shm agentik.db-wal memory-md/agentik-mem-*/ + +# Runtime-данные standalone-агента (db/memory/skills при локальном запуске) +/standalone/agentik/ +hs_err_pid*.log +core.* diff --git a/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt b/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt index 0f626f3..a1e1c2d 100644 --- a/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt +++ b/agentik-tui/src/commonTest/kotlin/pw/binom/agentik/tui/FakeAgent.kt @@ -7,6 +7,7 @@ import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Conversation import pw.binom.agentik.outbox.Event @@ -23,6 +24,7 @@ internal class FakeAgent( private val conversationFactory: () -> FakeConversation = { FakeConversation() }, ) : Agent { override val id: String = "fake" + override val info: AgentInfo = AgentInfo(name = "fake") var createCount: Int = 0 private set val conversations = mutableListOf() diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt index ddaf3e9..ae7f932 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt @@ -16,6 +16,7 @@ import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.Conversation import kotlin.time.Instant @@ -27,9 +28,16 @@ import kotlin.time.Instant * **Storage handles** ([journal], [outbox], [conversationStore]) — read-only * views на серверные хранилища. Запись — только через команды * [createConversation] / [deleteConversation] / [renameConversation]. + * + * Конструируется через suspend [Companion.create], который **eagerly** + * фетчит [info] (`GET {baseUrl}`) и сохраняет снимок в поле. Это убирает + * необходимость в `lazy { runBlocking { ... } }` на горячем пути — + * `runBlocking` живёт один раз в [Companion.create], оттуда же [AgentikAgent] + * его и вызывает (там он приемлем: одноразовая инициализация агента). */ -internal class AgentClient( +internal class AgentClient private constructor( override val id: String, + override val info: AgentInfo, private val baseUrl: String, private val httpClient: HttpClient, ) : Agent { @@ -74,4 +82,32 @@ internal class AgentClient( override fun close() { httpClient.close() } + + companion object { + /** + * Создаёт [AgentClient] и eagerly загружает [Agent.info] + * (`GET {baseUrl}` на серверном фасаде). Любой сбой сети на этом + * этапе пробрасывается как исключение — агент без `info` бесполезен + * (UI/A2A сразу упрутся в `agent.info`). + * + * Single-shot инициализация, `runBlocking` тут допустим (см. KDoc + * класса). Хосты, которым нужен полностью неблокирующий старт, + * могут обернуть вызов в свой `CoroutineScope`. + */ + suspend fun create( + id: String, + baseUrl: String, + httpClient: HttpClient, + ): AgentClient { + val agentUrl = baseUrl.trimEnd('/') + val info: AgentInfo = httpClient.get(agentUrl).body() + return AgentClient( + id = id, + info = info, + baseUrl = agentUrl, + httpClient = httpClient, + ) + } + } } + diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt index 82d4c79..ee2fd00 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import pw.binom.agentik.journal.ConversationRecord @@ -95,7 +96,7 @@ fun AgentikAgent( token: String? = null, ): Agent { val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token) - val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient) + val client = runBlocking { AgentClient.create(id = id, baseUrl = baseUrl, httpClient = httpClient) } return wrapWithLocalConversationCache(client, scopeClient = client) } diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/ConversationClient.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/ConversationClient.kt index cab2d15..339c092 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/ConversationClient.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/ConversationClient.kt @@ -59,6 +59,9 @@ internal class ConversationClient( httpClient.post("$convUrl/messages") { contentType(ContentType.Application.Json) setBody(SendPayload(content, context)) + // Сервер отвечает только по завершении хода агента (LLM + тулы), + // а это минуты, а не 15 секунд дефолтного request-timeout. + noReadTimeout() } } diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpConversationStore.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpConversationStore.kt index 9c355f3..d016951 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpConversationStore.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpConversationStore.kt @@ -33,7 +33,10 @@ internal class HttpConversationStore( private val agentUrl: String = baseUrl.trimEnd('/') override suspend fun get(id: String): ConversationRecord? { - val response = httpClient.get("$agentUrl/conversations/$id") + // `/record` (а НЕ `/conversations/{id}`): последний отдаёт + // ConversationSnapshot для `AgentClient.getConversation`, у которого + // другой shape (handle + isImageSupported, без createdAt/updatedAt). + val response = httpClient.get("$agentUrl/conversations/$id/record") if (response.status == HttpStatusCode.NotFound) return null check(response.status == HttpStatusCode.OK) { "conversationStore.get($id): server returned ${response.status}" diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt index a94c8f7..a3da0e1 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpEventStore.kt @@ -53,7 +53,7 @@ internal class HttpEventStore( append("$agentUrl/outbox/events") if (after != null) append("?after=$after") } - httpClient.prepareGet(url) { noSseReadTimeout() } + httpClient.prepareGet(url) { noReadTimeout() } .execute { response -> check(response.status == HttpStatusCode.OK) { "events: server returned ${response.status}" @@ -74,7 +74,7 @@ internal class HttpEventStore( append("$agentUrl/events") if (after != null) append("?after=$after") } - httpClient.prepareGet(url) { noSseReadTimeout() } + httpClient.prepareGet(url) { noReadTimeout() } .execute { response -> check(response.status == HttpStatusCode.OK) { "agentEvents: server returned ${response.status}" @@ -104,7 +104,7 @@ internal class HttpEventStore( append("$agentUrl/conversations/$conversationId/events") if (after != null) append("?after=$after") } - httpClient.prepareGet(url) { noSseReadTimeout() } + httpClient.prepareGet(url) { noReadTimeout() } .execute { response -> check(response.status == HttpStatusCode.OK) { "conversationEvents: server returned ${response.status}" diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/SseTimeout.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/SseTimeout.kt index fd28041..68bdc96 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/SseTimeout.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/SseTimeout.kt @@ -8,18 +8,23 @@ import io.ktor.client.request.HttpRequestBuilder * Отключает request/connect/socket-таймауты для конкретного запроса через * [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS]. * - * Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не - * использует плагин `SSE`, поэтому движок не считает запрос SSE-шным - * (`HttpRequestBuilder.supportsRequestTimeout` проверяет - * `body is SSEClientContent`, а у нас тело — обычный GET без тела). - * Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию - * **15000 мс**) молча убивает долгий idle-стрим через 15 секунд. + * Зачем: два вида запросов живут дольше дефолтных 15 секунд: + * - **SSE-чтение** ([readSse]) — читает `bodyAsChannel()` руками и не + * использует плагин `SSE`, поэтому движок не считает запрос SSE-шным + * ([HttpRequestBuilder.supportsRequestTimeout] проверяет + * `body is SSEClientContent`, а у нас тело — обычный GET без тела). + * - **`POST /conversations/{id}/messages`** — сервер отвечает не сразу, а + * только когда ход агента полностью завершён (LLM + тулы). Реальный ход + * легко длится минуты, и дефолтный request-timeout убивал бы его на + * 15-й секунде, обрывая ещё живой ход на сервере. + * Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` + * (по умолчанию **15000 мс**) молча убивает такой запрос. * * Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при * установленном capability мутирует его поля через `?:`, так что шаренный * инстанс мог бы утечь между запросами. */ -internal fun HttpRequestBuilder.noSseReadTimeout() { +internal fun HttpRequestBuilder.noReadTimeout() { setCapability( HttpTimeoutCapability, HttpTimeoutConfig( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e1f052..03f52e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ logback = "1.5.18" mosaic = "0.18.0" clikt = "5.0.3" kotlinx-cli = "0.3.6" -ksqlite = "0.1.3" +ksqlite = "0.1.4" junit = "4.13.2" [plugins] diff --git a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationRecord.kt b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationRecord.kt index 902b00c..3f19969 100644 --- a/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationRecord.kt +++ b/journal-api/src/commonMain/kotlin/pw/binom/agentik/journal/ConversationRecord.kt @@ -1,10 +1,12 @@ package pw.binom.agentik.journal +import kotlinx.serialization.Serializable import kotlin.time.Instant /** * Snapshot диалога. В таблице `conversation` хранится как есть. */ +@Serializable data class ConversationRecord( val id: String, val title: String?, diff --git a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt index 326543e..c9d756f 100644 --- a/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt +++ b/outbox-api/src/commonMain/kotlin/pw/binom/agentik/outbox/Event.kt @@ -12,8 +12,13 @@ import kotlin.time.Instant * порядка при равных timestamps. * * Базовая структура хода: - * `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`. + * `Working` → `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`. * `StartReasoning` может отсутствовать, если агент не показывал рассуждения. + * `Working` — маркер «агент принял запрос и пошёл обрабатывать», эмитится + * синхронно в `Conversation.send()` ДО старта LLM-цикла (и возможной долгой + * очереди turnLock'а). Парный терминатор не нужен: `End`/`Interrupted`/`Error` + * уже закрывают ход. UI использует `Working` чтобы показать спиннер ещё до + * первого токена ответа. * * **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`; * при миграции в `:outbox-api` был оставлен typealias в `:proto` для @@ -33,6 +38,16 @@ sealed interface Event { @SerialName("image") IMAGE } + /** + * Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до** + * [StartReasoning]/[StartResponse], синхронно из `Conversation.send()`, + * чтобы UI мог показать спиннер ещё до первого токена ответа. + * Терминатор хода ([End]/[Interrupted]/[Error]) — парный. + */ + @Serializable + @SerialName("working") + data class Working(override val date: Instant) : Event + /** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */ @Serializable @SerialName("start_reasoning") diff --git a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt index 2b6d29e..f4d2061 100644 --- a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt +++ b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt @@ -28,6 +28,26 @@ interface Agent : AutoCloseable { /** Идентификатор агента. */ val id: String + /** + * Публичное представление имени/назначения агента — то, что агент + * сам "рассказывает о себе" клиентам и UI. + * + * Контрактно отличается от [id]: + * - [id] — opaque stable identifier (логи, корреляция, A2A contextId); + * - [info.name] — **человекочитаемое** имя, которое пользователь + * может сменить через конфиг или UI; может меняться в течение жизни + * агента. + * + * Используется HTTP-фасадом `:server` (endpoint под `path` самого + * агента) и A2A-фасадом (для заполнения AgentCard `name` / `description`). + * + * Доступ к [info] всегда дешёвый — это snapshot `data class` без побочных + * эффектов. Конкретные реализации ([MutableAgent]) могут выставлять + * `info` конструкторным параметром; [Agent] контрактно не требует + * реактивности — клиенты получают снимок в момент обращения. + */ + val info: AgentInfo + /** * Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки). * После [close] вызовы [createConversation] / [getConversation] и т.п. diff --git a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/AgentInfo.kt b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/AgentInfo.kt new file mode 100644 index 0000000..bbf7500 --- /dev/null +++ b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/AgentInfo.kt @@ -0,0 +1,34 @@ +package pw.binom.agentik.proto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Идентификационная информация об [Agent] — публичное представление для + * UI/A2A-агентов/IRC/визуальных дашбордов: имя ("как зовут ассистента"), + * краткое описание ("что он делает") и зачем он может быть полезен + * ("в каких задачах помогает"). + * + * Контрактное отличие от [Agent.id]: + * - [Agent.id] — стабильный opaque identifier для корреляции, логов и + * транспорта (A2A `contextId`, IRC CTCP-запросы, etc). Менять нельзя. + * - [AgentInfo.name] — **человекочитаемое имя**, которое показывается + * клиенту и которое пользователь может настроить (например, + * `AGENTIK_NAME` в standalone-конфиге). Может меняться. + * + * Wire-формат (через `:server` JSON-фасад): `name` — обязательное поле, + * `description` и `usefulness` — опциональные, могут отсутствовать. + * + * @property name Человекочитаемое имя ассистента. Обязательное. + * @property description Краткое описание (что агент делает/кто он). + * Не сериализуется, если `null`. + * @property usefulness Зачем агент может быть полезен. Не сериализуется, + * если `null`. + */ +@Serializable +data class AgentInfo( + val name: String, + val description: String? = null, + @SerialName("usefulness") + val usefulness: String? = null, +) diff --git a/proto/src/commonTest/kotlin/pw/binom/agentik/proto/AgentInfoSerializationTest.kt b/proto/src/commonTest/kotlin/pw/binom/agentik/proto/AgentInfoSerializationTest.kt new file mode 100644 index 0000000..79a1d9d --- /dev/null +++ b/proto/src/commonTest/kotlin/pw/binom/agentik/proto/AgentInfoSerializationTest.kt @@ -0,0 +1,70 @@ +package pw.binom.agentik.proto + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Сериализация [AgentInfo] — контракт, по которому + * `GET {path}` отдаёт JSON клиенту/A2A. + * + * JSON-форма `description` и `usefulness` исключаются, когда null + * (благодаря `explicitNulls = false` в `agentikJson`). + */ +class AgentInfoSerializationTest { + + private val json = Json { + explicitNulls = false + } + + @Test + fun onlyName_whenOptionalsAreNull() { + val info = AgentInfo(name = "agentik") + val s = json.encodeToString(info) + assertEquals("""{"name":"agentik"}""", s) + } + + @Test + fun allFields_whenAllSet() { + val info = AgentInfo( + name = "Hermes", + description = "Ассистент-разработчик на Kotlin", + usefulness = "Помогает писать код, искать баги, объяснять legacy", + ) + val s = json.encodeToString(info) + assertEquals( + """{"name":"Hermes","description":"Ассистент-разработчик на Kotlin","usefulness":"Помогает писать код, искать баги, объяснять legacy"}""", + s, + ) + } + + @Test + fun descriptionOnly_usefulnessOmitted() { + val info = AgentInfo(name = "x", description = "d") + val s = json.encodeToString(info) + assertEquals("""{"name":"x","description":"d"}""", s) + } + + @Test + fun usefulnessOnly_descriptionOmitted() { + val info = AgentInfo(name = "x", usefulness = "u") + val s = json.encodeToString(info) + assertEquals("""{"name":"x","usefulness":"u"}""", s) + } + + @Test + fun roundTrip_preservesFields() { + val original = AgentInfo(name = "Hermes", description = "d", usefulness = "u") + val decoded = json.decodeFromString(json.encodeToString(original)) + assertEquals(original, decoded) + } + + @Test + fun defaultConstructor_optionalsAreNull() { + val info = AgentInfo(name = "x") + assertNull(info.description) + assertNull(info.usefulness) + } +} diff --git a/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt b/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt index d679680..047c057 100644 --- a/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt +++ b/server/src/commonMain/kotlin/pw/binom/agentik/server/Routes.kt @@ -33,6 +33,19 @@ internal fun Route.agentikRoutes(agent: Agent) { call.respondText("ok") } + /** + * `GET {path}` — публичная информация об агенте (`name` + + * опциональные `description` / `usefulness`). Контрактно отличается + * от `/health` тем, что отдаёт человекочитаемые данные об агенте, + * а не просто "alive"-сигнал. + * + * Используется UI-дашбордами / A2A-fetch'ерами AgentCard для + * отображения карточки агента без подписки на события. + */ + get("") { + call.respond(agent.info) + } + // ---- Agent: множество диалогов ---- get("/conversations") { @@ -54,6 +67,25 @@ internal fun Route.agentikRoutes(agent: Agent) { else call.respond(c.snapshot()) } + /** + * `GET /conversations/{id}/record` — лёгкая метадата записи + * ([pw.binom.agentik.journal.ConversationRecord]: id/title/isTemporal/ + * createdAt/updatedAt) БЕЗ handle'а и image-support флагов. + * + * Отдельно от `GET /conversations/{id}` (который отдаёт + * [pw.binom.agentik.proto.ConversationSnapshot] — его ждёт + * `AgentClient.getConversation`): это тот же shape, что и элемент + * списка `GET /conversations`, его читает клиентский кэш + * (`HttpConversationStore.get`), чтобы дотянуть запись после + * outbox-события `AgentEvent.Created`. + */ + get("/conversations/{id}/record") { + val id = call.parameters["id"]!! + val rec = agent.conversationStore.get(id) + if (rec == null) call.respond(HttpStatusCode.NotFound) + else call.respond(rec) + } + delete("/conversations/{id}") { val id = call.parameters["id"]!! call.respond(if (agent.deleteConversation(id)) HttpStatusCode.NoContent else HttpStatusCode.NotFound) diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt new file mode 100644 index 0000000..ae86809 --- /dev/null +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/AgentInfoRouteTest.kt @@ -0,0 +1,169 @@ +package pw.binom.agentik.server + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import pw.binom.agentik.journal.ConversationStore +import pw.binom.agentik.journal.JournalStore +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.OutboxStore +import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.Conversation +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * Интеграционные тесты endpoint'а `GET {path}` (без суффикса) — отдаёт + * публичную информацию об агенте ([AgentInfo]) для UI/A2A/IRC. + * + * Проверяет: + * - happy path с полным [AgentInfo] (name + description + usefulness); + * - минимальный [AgentInfo] (только name, optional поля null/отсутствуют); + * - JSON-shape: только присутствующие в [AgentInfo] поля, `null`-опциональные + * выкинуты (благодаря `explicitNulls = false` в `agentikJson`); + * - endpoint доступен без авторизации (auth отключён в этом тесте; + * bearer-token тест уже в [BearerTokenTest]). + */ +class AgentInfoRouteTest { + + private lateinit var server: EmbeddedServer<*, *> + private var port: Int = 0 + + @Serializable + private data class InfoDto(val name: String, val description: String? = null, val usefulness: String? = null) + + @BeforeTest + fun setup() { + val fake = object : Agent { + override val id: String = "test-agent" + override val info: AgentInfo = AgentInfo( + name = "Hermes", + description = "Kotlin-ассистент для agentik", + usefulness = "Помогает писать код и искать баги", + ) + override val journal: JournalStore = object : JournalStore { + override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList() + override suspend fun count(conversationId: String): Long = 0L + override suspend fun count(conversationId: String, after: Instant): Long = 0L + override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow() + override fun close() {} + } + override val outbox: OutboxStore = object : OutboxStore { + override fun events(after: Instant?) = emptyFlow() + override fun agentEvents(after: Instant?) = emptyFlow() + override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow() + override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun close() {} + } + override val conversationStore: ConversationStore = object : ConversationStore { + override suspend fun get(id: String) = null + override suspend fun list(offset: Int, limit: Int) = emptyList() + override fun close() {} + } + override fun createConversation(temp: Boolean): Conversation = TODO("not used") + override suspend fun getConversation(id: String): Conversation? = null + override suspend fun deleteConversation(id: String): Boolean = false + override suspend fun renameConversation(id: String, title: String?): Instant? = null + override fun close() {} + } + server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") { + routing { agentikAgent(fake, path = "/agentik", token = null) } + }.start(wait = false) + port = runBlocking { server.engine.resolvedConnectors()[0].port } + } + + @AfterTest + fun tearDown() { + server.stop(100, 200) + } + + private fun client(): HttpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + + @Test + fun get_returns_full_info_as_json() = runBlocking { + val response = client().get("http://127.0.0.1:$port/agentik") + assertEquals(HttpStatusCode.OK, response.status) + val dto = response.body() + assertEquals(InfoDto("Hermes", "Kotlin-ассистент для agentik", "Помогает писать код и искать баги"), dto) + } + + @Test + fun get_body_shape_has_all_three_fields() = runBlocking { + val response = client().get("http://127.0.0.1:$port/agentik") + val raw = response.bodyAsText() + // explicitNulls=false → все три поля всегда присутствуют у full-info agent'а. + assertEquals( + """{"name":"Hermes","description":"Kotlin-ассистент для agentik","usefulness":"Помогает писать код и искать баги"}""", + raw, + ) + } + + @Test + fun get_returns_minimal_info_when_only_name_set() { + // override agent: minimal info (только name). + // Storage handles — noop-stub'ы (см. BearerTokenTest: agentikAgent + // вычитывает их eagerly при старте). + val minimal = object : Agent { + override val id: String = "minimal" + override val info: AgentInfo = AgentInfo(name = "agentik") + override val journal: JournalStore = object : JournalStore { + override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList() + override suspend fun count(conversationId: String): Long = 0L + override suspend fun count(conversationId: String, after: Instant): Long = 0L + override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow() + override fun close() {} + } + override val outbox: OutboxStore = object : OutboxStore { + override fun events(after: Instant?) = emptyFlow() + override fun agentEvents(after: Instant?) = emptyFlow() + override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow() + override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun close() {} + } + override val conversationStore: ConversationStore = object : ConversationStore { + override suspend fun get(id: String) = null + override suspend fun list(offset: Int, limit: Int) = emptyList() + override fun close() {} + } + override fun createConversation(temp: Boolean): Conversation = TODO("not used") + override suspend fun getConversation(id: String): Conversation? = null + override suspend fun deleteConversation(id: String): Boolean = false + override suspend fun renameConversation(id: String, title: String?): Instant? = null + override fun close() {} + } + val server2 = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") { + routing { agentikAgent(minimal, path = "/agentik", token = null) } + }.start(wait = false) + try { + val p = runBlocking { server2.engine.resolvedConnectors()[0].port } + val raw = runBlocking { client().get("http://127.0.0.1:$p/agentik").bodyAsText() } + // explicitNulls=false выкидывает null-опциональные поля → только {"name":"agentik"}. + assertEquals("""{"name":"agentik"}""", raw) + } finally { + server2.stop(100, 200) + } + } +} diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt index e3a059a..423c59f 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt @@ -19,6 +19,7 @@ import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.Conversation import kotlin.time.Instant import kotlin.test.Test @@ -41,6 +42,7 @@ class BearerTokenTest { */ private class FakeAgent( override val id: String = "test", + override val info: AgentInfo = AgentInfo(name = "test"), ) : Agent { override val journal: JournalStore = object : JournalStore { override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList() diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt new file mode 100644 index 0000000..e745881 --- /dev/null +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/ConversationRoutesTest.kt @@ -0,0 +1,181 @@ +package pw.binom.agentik.server + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.ConversationStore +import pw.binom.agentik.journal.JournalStore +import pw.binom.agentik.journal.inmemory.InMemoryJournalStore +import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore +import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.OutboxStore +import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * Тесты `GET /conversations` и `GET /conversations/{id}/record` — обе отдают + * [ConversationRecord] (лёгкую метадату элемента списка). + * + * Это **регрессия** на два бага, из-за которых клиентский + * `wrapWithLocalConversationCache` не наполнялся: + * 1. [ConversationRecord] не был `@Serializable` → `GET /conversations` + * падал 500 `Serializer for class ... not found`; + * 2. клиентский `HttpConversationStore.get` читал `ConversationRecord` из + * `GET /conversations/{id}`, который отдаёт `ConversationSnapshot` + * (другой shape: handle + isImageSupported, без createdAt/updatedAt) + * → `MissingFieldException: Field 'createdAt' is required`. Для этого + * заведён отдельный `/record`-endpoint. + */ +class ConversationRoutesTest { + + private lateinit var server: EmbeddedServer<*, *> + private lateinit var journal: InMemoryJournalStore + private lateinit var store: InMemoryMutableConversationStore + private var port: Int = 0 + + private val t0 = Instant.parse("2026-09-22T10:00:00Z") + + @BeforeTest + fun setup() { + journal = InMemoryJournalStore() + store = InMemoryMutableConversationStore() + val fakeAgent = FakeAgent(journal, store) + server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") { + routing { agentikAgent(fakeAgent, path = "/agentik", token = null) } + }.start(wait = false) + port = runBlocking { server.engine.resolvedConnectors()[0].port } + } + + @AfterTest + fun tearDown() { + server.stop(100, 200) + journal.close() + store.close() + } + + private fun client(): HttpClient = HttpClient(CIO) { + install(ContentNegotiation) { json(agentikJson) } + } + + private fun seed(id: String, title: String?, isTemporal: Boolean = false, updatedAt: Instant = t0) = + runBlocking { + store.upsert(ConversationRecord(id, title, isTemporal, t0, updatedAt)) + } + + @Test + fun `list returns serializable conversation records`() = runBlocking { + seed("c1", "Первый", updatedAt = t0) + seed("c2", null, isTemporal = true, updatedAt = t0 + 1.seconds) + + val client = client() + try { + val resp = client.get("http://127.0.0.1:$port/agentik/conversations") + assertEquals(HttpStatusCode.OK, resp.status) + // До фикса `@Serializable` здесь был 500. + val records = resp.body>() + // Сортировка хранилища — updatedAt DESC. + assertEquals(listOf("c2", "c1"), records.map { it.id }) + assertEquals(listOf(null, "Первый"), records.map { it.title }) + assertEquals(Instant.parse("2026-09-22T10:00:01Z"), records[0].updatedAt) + } finally { + client.close() + } + } + + @Test + fun `list body carries timestamps that the client cache needs`() = runBlocking { + seed("c1", "Первый") + + val client = client() + try { + val raw = client.get("http://127.0.0.1:$port/agentik/conversations").bodyAsText() + assertEquals( + """[{"id":"c1","title":"Первый","isTemporal":false,""" + + """"createdAt":"2026-09-22T10:00:00Z","updatedAt":"2026-09-22T10:00:00Z"}]""", + raw, + ) + } finally { + client.close() + } + } + + @Test + fun `record returns conversation record by id`() = runBlocking { + seed("c1", "Первый", isTemporal = true) + + val client = client() + try { + val resp = client.get("http://127.0.0.1:$port/agentik/conversations/c1/record") + assertEquals(HttpStatusCode.OK, resp.status) + val rec = assertNotNull(resp.body()) + assertEquals("c1", rec.id) + assertEquals("Первый", rec.title) + assertEquals(true, rec.isTemporal) + assertEquals(t0, rec.createdAt) + } finally { + client.close() + } + } + + @Test + fun `record for unknown conversation returns not found`() = runBlocking { + val client = client() + try { + val resp = client.get("http://127.0.0.1:$port/agentik/conversations/never-existed/record") + assertEquals(HttpStatusCode.NotFound, resp.status) + } finally { + client.close() + } + } + + /** + * Минимальный stub-агент: реальные [JournalStore] и + * [InMemoryMutableConversationStore]; outbox — noop, conversation-хендлы + * (`getConversation`/`createConversation`) тесту не нужны. + */ + private class FakeAgent( + private val js: JournalStore, + private val cs: ConversationStore, + ) : Agent { + override val id: String = "test" + override val info: AgentInfo = AgentInfo(name = "test") + override val journal: JournalStore = js + override val outbox: OutboxStore = object : OutboxStore { + override fun events(after: Instant?) = emptyFlow() + override fun agentEvents(after: Instant?) = emptyFlow() + override fun conversationEvents(after: Instant?, conversationId: String?) = + emptyFlow() + + override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + override fun close() {} + } + override val conversationStore: ConversationStore = cs + + override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation = + TODO("not used") + + override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null + override suspend fun deleteConversation(id: String): Boolean = false + override suspend fun renameConversation(id: String, title: String?): Instant? = null + override fun close() {} + } +} diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt index 7569089..5a2e343 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/JournalRoutesCountTest.kt @@ -26,6 +26,7 @@ import pw.binom.agentik.journal.inmemory.InMemoryJournalStore import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -177,6 +178,7 @@ class JournalRoutesCountTest { */ private class FakeAgent(private val js: JournalStore) : Agent { override val id: String = "test" + override val info: AgentInfo = AgentInfo(name = "test") override val journal: JournalStore = js override val outbox: OutboxStore = object : OutboxStore { override fun events(after: Instant?) = emptyFlow() diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt index b5b5dbc..ac80a82 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -374,6 +374,7 @@ private fun runServer() { val agent = ChatAgent( id = "agentik", + info = config.agent.toAgentInfo(), mutableConversationStore = sqliteStores.conversations, messageStore = sqliteStores.messages, workingMemoryStore = sqliteStores.workingMemory, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index 9b482d8..07dac27 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -24,6 +24,7 @@ import pw.binom.agentik.memory.MemoryPrefetcher import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemorySystemGuidance import pw.binom.agentik.proto.Agent as ProtoAgent +import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.Event as OutboxEvent import pw.binom.agentik.outbox.CommonEvent @@ -83,6 +84,15 @@ import pw.binom.litert.LiteLlm */ internal class ChatAgent( override val id: String, + /** + * Публичное представление об агенте (имя + описание + чем полезен). + * Доступно через [ProtoAgent.info] — `:server` HTTP-фасад отдаёт его + * как `GET {path}`, A2A — использует для AgentCard. По умолчанию + * имя "agentik", описание/usefulness — отсутствуют (host может + * переопределить через standalone-конфиг `AGENTIK_NAME`, + * `AGENTIK_DESCRIPTION`, `AGENTIK_USEFULNESS`). + */ + override val info: AgentInfo = AgentInfo(name = "agentik"), private val mutableConversationStore: MutableConversationStore, private val messageStore: MutableJournalStore, private val workingMemoryStore: ContextStore, diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt index 13c4714..5c84f4e 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt @@ -170,6 +170,12 @@ class ConversationLoop( override suspend fun send(content: List, context: ProtoMessageContext?) { check(!state.isClosed) { "Conversation closed: $id" } val turnStarted = now() + // Working-маркер — самый первый event хода. Эмитим синхронно через + // tryEmit (events.tryEmit → outbox.append, без сетевого I/O), чтобы + // клиент увидел «агент работает» ещё до turnLock.withLock { launch } + // и до первого токена от LLM. Терминатор — End/Interrupted/Error + // (см. KDoc Event.Working). + emitEvent(pw.binom.agentik.outbox.Event.Working(date = turnStarted)) val userMessageId = newId("msg") val storageContext = context?.toStorage() diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt index 16bdb4b..267691a 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt @@ -7,6 +7,7 @@ import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.mcp.bridge.McpConfig +import pw.binom.agentik.proto.AgentInfo // Inlined here (AppLimits.kt удалён параллельным рефакторингом): сетевые капы // и env-parser limits живут рядом с тем, кто их использует, чтобы :config не @@ -62,7 +63,38 @@ data class AppConfig( * `null` — авторизация выключена. Источник: env `AGENTIK_A2A_TOKEN`. */ val a2aToken: String? = null, - ) + /** + * Публичное имя ассистента — то, что видит пользователь/UI/A2A. + * Источник: env `AGENTIK_NAME`. Default — `"agentik"`. + * + * Контрактное отличие от [Agent.id] — id opaque-stable для корреляции, + * `name` — человекочитаемый и может меняться. В терминологии + * [pw.binom.agentik.proto.AgentInfo] это `info.name`. + */ + val name: String = DEFAULT_AGENT_NAME, + /** + * Краткое описание ассистента (что он делает / кто он). + * Источник: env `AGENTIK_DESCRIPTION`. `null` — секция не отдаётся + * клиенту (см. [pw.binom.agentik.proto.AgentInfo.description]). + */ + val description: String? = null, + /** + * Зачем ассистент может быть полезен (в каких задачах помогает). + * Источник: env `AGENTIK_USEFULNESS`. `null` — секция не отдаётся. + */ + val usefulness: String? = null, + ) { + + /** + * Снапшот [pw.binom.agentik.proto.AgentInfo] из полей секции. + * Имя обязательно ([name] non-blank), остальные опциональные. + */ + fun toAgentInfo(): AgentInfo = AgentInfo( + name = name, + description = description, + usefulness = usefulness, + ) + } /** Долговременная память. */ @Serializable @@ -146,6 +178,7 @@ data class AppConfig( companion object { const val DEFAULT_PORT: Int = 8080 const val DEFAULT_DB_PATH: String = "./agentik.db" + const val DEFAULT_AGENT_NAME: String = "agentik" const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8 const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small" const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536 @@ -197,6 +230,9 @@ data class AppConfig( soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() }, authToken = env("AGENTIK_TOKEN")?.takeIf { it.isNotBlank() }, a2aToken = env("AGENTIK_A2A_TOKEN")?.takeIf { it.isNotBlank() }, + name = env("AGENTIK_NAME")?.takeIf { it.isNotBlank() } ?: DEFAULT_AGENT_NAME, + description = env("AGENTIK_DESCRIPTION")?.takeIf { it.isNotBlank() }, + usefulness = env("AGENTIK_USEFULNESS")?.takeIf { it.isNotBlank() }, ), llm = llm, mcp = McpConfig.fromEnv(env), diff --git a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index 70830fb..e802ae1 100644 --- a/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/commonTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -361,6 +361,36 @@ class ChatAgentTest { ) } + @Test + fun `Working event is the first event of a turn`() = runTest { + // Working-маркер обязан прийти самым первым событием хода, до + // StartReasoning/StartResponse/AppendText/End. Это позволяет UI + // показать спиннер сразу же при отправке, не дожидаясь первого + // токена от LLM. + val agent = newAgent() + fakeLlm.reply = "ok" + val conv = agent.createConversation(temp = false) + + val events = mutableListOf() + val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) } + } + conv.send(listOf(Content.Text("hi"))) + delay(50) + job.cancel() + + // Working — первый event хода (индекс 0). + assertTrue(events.isNotEmpty(), "no events captured: $events") + val first = events.first() + assertIs(first) + assertTrue(events.any { it is ProtoEvent.StartReasoning }, "no StartReasoning: $events") + assertTrue(events.any { it is ProtoEvent.StartResponse }, "no StartResponse: $events") + assertTrue(events.any { it is ProtoEvent.End }, "no End: $events") + + // Working не дублируется (emit'ится один раз на send()). + assertEquals(1, events.count { it is ProtoEvent.Working }, "Working emitted >1 times: $events") + } + @Test fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest { // Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel()