proto/server/client: Agent.info, Working-маркер хода, /record, таймауты запросов

- proto: Agent.info (AgentInfo: name/description/usefulness) — человекочитаемое
  имя отдельно от opaque id; сериализация и тесты
- server: GET {path} отдаёт Agent.info; GET /conversations/{id}/record —
  ConversationRecord без handle'а (для клиентского кэша после AgentEvent.Created)
- outbox: Event.Working — первый event хода, эмитится из Conversation.send()
  до LLM-цикла и turnLock, чтобы UI показал спиннер сразу
- standalone: AGENTIK_NAME/AGENTIK_DESCRIPTION/AGENTIK_USEFULNESS → AgentInfo
- client: AgentClient.create eagerly фетчит info (GET {baseUrl})
- client: HttpConversationStore.get читает /record (ConversationRecord,
  а не ConversationSnapshot — рассинхрон типов)
- client: noReadTimeout() на POST /conversations/{id}/messages — сервер отвечает
  по завершении всего хода агента (реально 0.5–144 с), дефолтные 15 с рвали
  живую реплику на клиенте
- journal-api: ConversationRecord @Serializable
- ksqlite 0.1.3 → 0.1.4
- .gitignore: runtime-данные standalone-агента и hs_err-дампы
This commit is contained in:
2026-09-28 10:09:34 +03:00
parent 4156e5f95f
commit 509763cf36
24 changed files with 681 additions and 16 deletions
@@ -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] и т.п.
@@ -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,
)
@@ -0,0 +1,70 @@
package pw.binom.agentik.proto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Сериализация [AgentInfo] — контракт, по которому
* `GET {path}` отдаёт JSON клиенту/A2A.
*
* JSON-форма `description` и `usefulness` исключаются, когда null
* (благодаря `explicitNulls = false` в `agentikJson`).
*/
class AgentInfoSerializationTest {
private val json = Json {
explicitNulls = false
}
@Test
fun onlyName_whenOptionalsAreNull() {
val info = AgentInfo(name = "agentik")
val s = json.encodeToString(info)
assertEquals("""{"name":"agentik"}""", s)
}
@Test
fun allFields_whenAllSet() {
val info = AgentInfo(
name = "Hermes",
description = "Ассистент-разработчик на Kotlin",
usefulness = "Помогает писать код, искать баги, объяснять legacy",
)
val s = json.encodeToString(info)
assertEquals(
"""{"name":"Hermes","description":"Ассистент-разработчик на Kotlin","usefulness":"Помогает писать код, искать баги, объяснять legacy"}""",
s,
)
}
@Test
fun descriptionOnly_usefulnessOmitted() {
val info = AgentInfo(name = "x", description = "d")
val s = json.encodeToString(info)
assertEquals("""{"name":"x","description":"d"}""", s)
}
@Test
fun usefulnessOnly_descriptionOmitted() {
val info = AgentInfo(name = "x", usefulness = "u")
val s = json.encodeToString(info)
assertEquals("""{"name":"x","usefulness":"u"}""", s)
}
@Test
fun roundTrip_preservesFields() {
val original = AgentInfo(name = "Hermes", description = "d", usefulness = "u")
val decoded = json.decodeFromString<AgentInfo>(json.encodeToString(original))
assertEquals(original, decoded)
}
@Test
fun defaultConstructor_optionalsAreNull() {
val info = AgentInfo(name = "x")
assertNull(info.description)
assertNull(info.usefulness)
}
}