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

169 lines
9.8 KiB
Markdown

# 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`.