Files
agentik/docs/ARCHITECTURE.md
T
subochev 0faad45f3d standalone: persist turn errors so polling clients see them
Event.Error уже эмитился в live-SSE, но если клиент подключился
после провала хода (или опрашивает историю через getMessages
вместо SSE), он видел только user-сообщение без следа, что ход
провалился. Это и нужно было поправить.

* :proto
  - Message.Error(id, message, code?, date) — терминальная
    персистентная проекция Event.Error. Audit-only; в live-стриме
    по-прежнему приходит Event.Error.

* :standalone
  - MessageRecord.Error с тем же контрактом.
  - SqliteMessageStore: kind "error", JSON-payload {message, code};
    encoding-ошибки round-trip покрыты тестом (с code и без).
  - ChatConversation.failTurn(message, code?): пишет MessageRecord.Error
    в audit (кроме temp-диалогов), затем эмитит Event.Error + Event.End.
    Все три exit-точки из runTurn (пустой parts, init-catch,
    stream-catch) теперь через failTurn.
  - ChatConversation: при ошибке стрима живой LiteConversation
    сбрасывается — следующий send пересоберёт его из working_memory.
    Раньше оставляли битую инстанцию вопреки KDoc класса.
  - toProto: маппит MessageRecord.Error → Message.Error.

* docs
  - STANDALONE.md: добавлен Message.Error в таблицу dual-log + абзац
    про персист ошибок (audit/working_memory, сброс liteConv).
  - ARCHITECTURE.md: Message.Error в сигнатуре Message, failTurn
    в ChatConversation, Message.Error в audit log.
  - Поправлен пример describe() в доке тулов (реальный формат —
    OpenAI-style function wrapper, а не голый JSON Schema).

Тесты:
  - ChatAgentTest: LLM failure → Event.Error + Event.End + Error
    record в audit + backfill через getMessages.
  - PersistenceTest: MessageRecord.Error round-trip (с code и без).
  - :standalone jvmTest 70 (было 69).

E2E: неверный model id (HTTP 400) → polling GET /messages теперь
возвращает user_message + error (без assistant_message).
2026-09-14 00:26:13 +03:00

9.8 KiB

Agentik — архитектура

Полевые заметки о структуре проекта на текущий момент. Подробности запуска — docs/STANDALONE.md, чек-лист native-сборки — NATIVE-COMPATIBILITY.md, открытые вопросы — IRC-QUESTIONS.md.

1. Контекст

agentik — runtime агента. Ядро на собственном протоколе (:proto), HTTP/SSE-фасад в :server, runtime-контейнер в :standalone. Раньше фасадов было два (AG-UI + A2A); AG-UI убран как устаревший, a2a-server остаётся заготовкой в libs.versions.toml и подключён в :standalone build.gradle.kts, но в Main.kt пока не монтируется.

LLM — за интерфейсом pw.binom.litert.LiteLlm. Реализации: pw.binom.litert.openai (HTTP/JSON поверх OpenAI-API, любая совместимая endpoint) и pw.binom.litert.google (встроенный LiteRT-LM движок для .litertlm/.task моделей).

MCP — io.modelcontextprotocol:kotlin-sdk-client (KMP). Подключаются stdio + streamable-HTTP серверы, тулы оборачиваются в LiteTool.

2. Модули

agentik
├─ :proto             KMP jvm + native.   Интерфейсы Agent/Conversation, Content,
│                                       Message, Event, AgentEvent. @SerialName
│                                       дискриминаторы snake_case на проводе.
├─ :server            KMP jvm + native.   HTTP/SSE фасад `Route.agentikAgent(agent,
│                                       path = "/agentik")`. Json DTO — свой
│                                       Snapshot-тип, чтобы протокол оставался
│                                       сериализационно-чистым.
├─ :client            JVM-only (пока).    Ktor-клиент к фасаду `:server`,
│                                       подключающий удалённый агент как
│                                       локальный `Agent`.
└─ :standalone        KMP jvm (linuxX64 — в работе). Runnable-контейнер:
                                       SqliteStores + ChatAgent + MCP + LiteLlm,
                                       поднимает Ktor (CIO) на AGENTIK_PORT.

Каталог версий — gradle/libs.versions.toml. Из внешних — только pw.binom.litert.*, pw.binom.a2a.*, app.cash.sqldelight, io.ktor:*, io.modelcontextprotocol:kotlin-sdk-client, org.jetbrains.kotlinx:*.

3. :proto — интерфейсы

Agent (см. proto/src/commonMain/.../Agent.kt):

  • id: String
  • createConversation(temp: Boolean): Conversation
  • suspend getConversation(id): Conversation?
  • suspend getConversations(offset, limit): List<Conversation>
  • getConversations(offset = 0): Flow<Conversation> — cold-flow paging через suspend-версию, PAGE_SIZE = 100.
  • events(after: Instant): Flow<AgentEvent> — replay-free, бэкфилл через snapshot.
  • deleteConversation(id): Boolean

Conversation:

  • isSupportImageInput / Output / isTemporal: Boolean
  • updatedAt: Instant
  • send(content: List<Content>) — write-only, ничего не возвращает.
  • interrupt() — отмена активного хода.
  • events(after): Flow<Event> — live, replay-free.
  • getMessages(after, offset, limit) + getMessages(after): Flow<Message> — paging.
  • rename(title) — мутация, бампит updatedAt.
  • AutoCloseable — close() идемпотентен.

Content = Text(body) | Image(data, mime). Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error. Event = StartReasoning | StartResponse | End | AppendText | AppendImage | ToolCall | ToolResult | Error. AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title).

Принцип: агент — источник истины для транскрипта и сессий. Клиент лишь рендерит Event-stream и кэширует историю.

4. :standalone — runtime-контейнер

Точка входа pw.binom.agentik.standalone.MainKt:

Main.kt
  ├─ LlmConfig.fromEnv()         → LlmConfig(backend, openai, google, systemPrompt)
  ├─ llmConfig.createLlm()       → LiteLlm (рефлексия для google)
  ├─ SqliteStores.open(dbPath)   → ConversationStore + MessageStore + WorkingMemoryStore
  ├─ McpRegistry.fromConfig(...) → список NamedTool из всех MCP-серверов
  ├─ ChatAgent(stores, llm, llmConfig, tools)
  └─ embeddedServer(CIO, port) { agentikAgent(agent, "/agentik") }

ChatAgent — реализация Agent:

  • live: Map<String, ChatConversation> — in-memory кэш активных сессий.
  • createConversation(temp): для temp — только кэш; для persistent — upsert(conversation) + workingMemory.append(System(systemPrompt)) атомарно, потом live[id] = ChatConversation(...).
  • getConversation(id): кэш → store (cold-load). Переоткрытие восстанавливает LiteConversation из workingMemory.list(id) (см. ниже).
  • send/events/interrupt/delete/rename проксируются в ChatConversation.

ChatConversation — реализация Conversation:

  • На каждый ход: workingMemory.append(User) → liteConv.sendStreamContents(...) → стрим LiteDelta → клиенту (AppendText / ToolCall / ToolResult).
  • На завершение стрима: messageStore.append(Assistant) + workingMemory.append(Assistant)
    • conversationStore.touch(id).
  • Tool-loop: на delta.toolCalls → emit Event.ToolCall → execute (tool.invoke(argsJson)) → emit Event.ToolResult → liteConv.addToolResult(callId, name, result) → продолжение стрима.
  • На ошибку хода (init/стрим LLM): failTurn → messageStore.append(Error) (audit; в working_memory не пишется) + Event.Error + Event.End, живой LiteConversation сбрасывается и пересобирается на следующем send.
  • LiteConversation живёт один на весь ChatConversation для обоих бэкендов: KV-cache движка сохраняется между ходами. Это контракт litert-api, не только Google-specific.
  • На interrupt() отменяется текущий Job и liteConv.interrupt().

WorkingMemory — read-write контекст, который видит LLM:

  • System(text, sourceMessageId=null) — системный промпт.
  • User/Assistant(sourceMessageId, content) — снимки реальных сообщений.
  • compact(dropFromOrderIdx, conversationId) — v1: DELETE rows ≥ order_idx, summarization-вставка отложена (нужен дизайн-проработка).

MessageStore — append-only аудит. На каждый ход дописываются UserMessage, AssistantMessage, ToolCall, ToolResult, Error. Никаких update/delete кроме каскада из ConversationStore.delete.

5. Слои персистентности

SqliteStores (jvmMain) — три интерфейса из commonMain, одна SQLite-БД. Схема (jvmMain/sqldelight/):

таблица поля роль
conversation id, title, is_temporal, created_at, updated_at карточки диалогов
message id, conversation_id, role, payload_json, created_at аудит-хвост
working_memory id, conversation_id, order_idx, source_message_id, payload_json, created_at контекст LLM

Content (text/image) и per-kind payload сериализуются в payload_json через kotlinx.serialization. Maps в каскадное удаление (ConversationStore.delete — одна транзакция).

6. Фасады

  • :server (HTTP+SSE) — основной, KMP, Ktor Route extension. Route.agentikAgent(agent, path = "/agentik") монтирует весь CRUD + live event-stream. Подробнее — docs/STANDALONE.md.
  • A2A (pw.binom.a2a:server) — заготовка в libs.versions.toml, подключён в :standalone для совместимости с зависимостями через :client, но не монтируется в Main.kt. Если/когда понадобится — Route.a2aAgent(...) (как у agui в старом дизайне).

7. Конфигурация (env)

env назначение
AGENTIK_PORT порт Ktor (default 8080)
AGENTIK_DB_PATH путь к SQLite (default ./agentik.db)
AGENTIK_SYSTEM_PROMPT текст системного промпта
AGENTIK_LLM_BACKEND openai (default) или google
OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL для backend=openai
AGENTIK_GOOGLE_MODEL_PATH / AGENTIK_GOOGLE_THREADS для backend=google
AGENTIK_MCP_CONFIG путь к mcp.json в формате Claude Desktop

8. Что осталось за рамками v1

  • Суммаризация WorkingMemory.compact() (пункт compact(dropFromOrderIdx) пуст).
  • Image input (Content.Image принимается, но LiteContentPart.Image сейчас дропается в Chat-цикле с warn-логом — модель видит только текст).
  • Auth на фасаде.
  • A2A transport facade в Main.kt.