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
@@ -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,
@@ -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,
@@ -170,6 +170,12 @@ class ConversationLoop(
override suspend fun send(content: List<ProtoContent>, 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()
@@ -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),
@@ -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<ProtoEvent>()
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<ProtoEvent.Working>(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()