Files
agentik/docs/ARCHITECTURE.md

176 lines
10 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`, `info: AgentInfo`
- read-only сторы: `journal: JournalStore`, `outbox: OutboxStore`,
`onlineOutbox: OnlineOutbox`, `conversationStore: ConversationStore`
- `createConversation(temp: Boolean): Conversation`
- `suspend getConversation(id): Conversation?`
- `suspend deleteConversation(id): Boolean`
- `suspend renameConversation(id, title): Instant?`
`Conversation`:
- `isSupportImageInput / Output / isTemporal: Boolean`, `title: String?`
- `updatedAt: Instant`
- `send(content: List<Content>, context: MessageContext? = null)` — write-only, ничего не возвращает.
- `interrupt()` — отмена активного хода.
- `getMessages(after, offset, limit): List<Message>` — paging.
- `rename(title)` — мутация, бампит `updatedAt`.
- `AutoCloseable` — `close()` идемпотентен.
`Content = Text(body) | Image(data, mime)` — из `:content-api` (там же `MessageContext`/`MessageOrigin`/`TurnTokens`).
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
События разделены на два потока (оба в `:outbox-api`):
- **durable** `Event` (`outbox.conversationEvents(after, id)`): `UserMessage |
AssistantMessage | ToolCall | ToolResult | ToolFailed | Interrupted | Error |
ConversationClosing | CompactionTriggered` — перезапрашиваются по курсору;
- **online** `OnlineEvent` (`onlineOutbox.onlineEvents(id)`): `Working | End |
StartReasoning | StartResponse | AppendText | AppendImage` — live-only,
никогда не сохраняются.
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title) | Touched(...)`.
Принцип: **агент — источник истины** для транскрипта и сессий. Клиент
лишь рендерит 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-вставка отложена (нужен дизайн-проработка).
**`JournalStore`** — 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`.