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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user