# Standalone — рантайм агента agentik `standalone` — это исполняемое JVM-приложение (точка входа `pw.binom.agentik.standalone.MainKt`), которое поднимает реальный агент `ChatAgent` (stateful, SQLite-персистентный) и навешивает на него HTTP+SSE фасад `:server`. LLM-движок выбирается через `AGENTIK_LLM_BACKEND` — на v1 поддерживаются `litert-openai` (любой OpenAI-совместимый endpoint) и `litert-google` (on-device движок LiteRT-LM 0.17.0 через нативную `.so`-библиотеку из litertlm-jvm 0.17.0). Этот документ описывает, как `standalone` собран и как его расширять. --- ## 1. Что в коробке после `git clone` ``` :proto — единое ядро протокола (KMP, commonMain) Agent / Conversation / Event / Message / Content / AgentEvent stateful: агент сам хранит историю и working memory :server — HTTP+SSE фасад :proto public Route.agentikAgent(agent, path = "/agentik") :skills — парсер и каталог навыков (KMP, commonMain + jvmMain-загрузчик) SKILL.md (opencode frontmatter) / *.yaml, renderSystemPromptSection() :standalone — JVM-рантайм с реальным LLM-агентом ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google) default 8080: GET /health health check POST /agentik/conversations создать диалог (201) GET /agentik/conversations список GET /agentik/conversations/{id} один диалог PATCH /agentik/conversations/{id} переименовать DELETE /agentik/conversations/{id} удалить (204) POST /agentik/conversations/{id}/messages отправить user-сообщение (202) POST /agentik/conversations/{id}/interrupt прервать текущий ход (202) GET /agentik/conversations/{id}/messages страница истории GET /agentik/conversations/{id}/events SSE live-события хода GET /agentik/events SSE live-события агента ``` Полная таблица эндпоинтов — в `docs/ARCHITECTURE.md` (раздел «:server»). --- ## 2. Архитектура слоёв ``` клиенты транспорт ┌───────────────┐ ┌─────────────────────────────┐ │ Web / CLI / │ ──HTTP──► │ Route.agentikAgent(agent) │ │ desktop │ ──SSE───► │ :server (Ktor + Netty) │ │ │ └──────────────┬──────────────┘ └───────────────┘ │ ▼ pw.binom.agentik.proto.Agent (ChatAgent) │ ┌───────────────┴───────────────┐ ▼ ▼ ChatConversation.send(content) agent.events / agent.getConversations │ ▼ ┌──────────────────────────────────────┐ │ 1. audit: append UserMessage │ │ 2. working_memory: append User │ │ 3. ensureLiteConversation: │ │ first turn → create from WM; │ │ next turns → reuse (KV-cache) │ │ 4. sendStreamContents → emit │ │ StartResponse / AppendText / │ │ End │ │ 5. audit + WM: append AssistantMessage│ └──────────────────────────────────────┘ │ │ ▼ ▼ Conversation.events(after) Conversation.getMessages(after) (live, no replay) (история) ``` Слои рантайма: ``` :standalone ├── persistence/ ← интерфейсы и records (commonMain, без зависимостей) │ ConversationStore / MessageStore / WorkingMemoryStore │ ConversationRecord / MessageRecord / WorkingMemoryEntry / Content │ Payload.kt — JSON-сериализация ├── persistence/sqlite/ ← JVM: SQLDelight-схема + три SQLite-реализации │ SqliteStores.open(path | inMemory) │ src/jvmMain/sqldelight/.../*.sq ├── llm/ ← LlmConfig (env → OpenAI/Google), backend-agnostic └── agent/ ← ChatAgent + ChatConversation (stateful, long-lived LiteConv) ``` Ключевой инвариант: **разговор живёт внутри агента, а не в клиенте и не в транспорте.** Транспорт — лишь сериализатор: HTTP пишет в/читает из `:server`-эндпоинтов, SSE шлёт события. У них нет своего состояния диалога. --- ## 3. Точка входа: `pw.binom.agentik.standalone.MainKt` ```kotlin fun main() { val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080 val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db" val llmConfig = LlmConfig.fromEnv() val llm = llmConfig.createLlm() val stores = SqliteStores.open(dbPath = dbPath) val agent = ChatAgent( id = "agentik", stores = stores, llm = llm, llmConfig = llmConfig, ) val server = embeddedServer(Netty, port = port) { routing { get("/health") { call.respondText("ok") } agentikAgent(agent, path = "/agentik") } } Runtime.getRuntime().addShutdownHook(Thread { agent.close(); stores.close(); llm.close() }) server.start(wait = true) } ``` Один `ChatAgent` отвечает и за диалоги (`/agentik/conversations/...`), и за live-события (`/agentik/events`). Все три ресурса — БД, LLM, Netty — корректно закрываются в shutdown-хуке. --- ## 4. Контракт `Agent` (от `pw.binom.agentik.proto`) Реализация **обязана** уметь: | метод | смысл | |---|---| | `id: String` | идентификатор агента | | `createConversation(temp: Boolean): Conversation` | новая сессия, `temp=true` — не персистить | | `getConversation(id): Conversation?` | достать по id, `null` если нет | | `deleteConversation(id): Boolean` | удалить | | `getConversations(offset, limit)` | страница списка | | `events(after: Instant): Flow` | live-события по множеству разговоров | Реализация `Conversation`: | метод | смысл | |---|---| | `id: String` | идентификатор диалога | | `title: String?` | заголовок (может быть `null`) | | `isTemporal: Boolean` | `true` = не персистить (`temp=true` при создании) | | `isSupportImageInput/Output: Boolean` | мультимодальные возможности (для v1 оба `false`) | | `updatedAt: Instant` | последний `send`/`rename` | | `send(content: List)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти | | `interrupt()` | остановить текущий ход (best-effort) | | `events(after): Flow` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) | | `getMessages(after, offset, limit)` | страница истории | | `rename(title)` | переименовать | | `close()` | освободить ресурсы | Главное: `send` ничего не возвращает. Чтобы получить события, нужно **отдельно** подписаться на `events(after)` ДО `send` либо сразу после — поток событий стартует с момента подписки, бэкфилл через `getMessages`. --- ## 5. Persistence — dual-log `ChatAgent` хранит каждую сессию в двух логически разных таблицах: | таблица | назначение | мутации | |---|---|---| | `message` | append-only audit log. Все user/assistant/tool-call/tool-result/error сообщения. Никогда не редактируется (кроме каскадного `DELETE` при удалении диалога). | только `INSERT` | | `working_memory` | mutable LLM-контекст. System-prompt + текущая история + (в v2) суммаризации. | `INSERT`, `compact(dropFromIdx, summary)` | Маппинг `:proto.Message ↔ MessageRecord` живёт в `ChatConversation.kt` (`toProto`/`toStorage`) — сами `MessageRecord` намеренно НЕ зависят от `:proto`, чтобы можно было сменить транспорт без миграции таблиц. Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`. **Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов). ### `JournalStore` ```kotlin suspend fun append(record: MessageRecord) suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List suspend fun listAll(conversationId: String): List ``` ### `ContextStore` ```kotlin suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) suspend fun list(conversationId: String): List suspend fun clear(conversationId: String) suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long ``` `compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста). ### `ConversationStore` ```kotlin suspend fun upsert(record: ConversationRecord) suspend fun get(id: String): ConversationRecord? suspend fun delete(id: String): Boolean // каскадно чистит message + working_memory suspend fun list(offset: Int, limit: Int): List suspend fun rename(id: String, title: String?): Instant? suspend fun touch(id: String, now: Instant) ``` Все три store — `AutoCloseable`; корневой ресурс `SqliteStores` закрывает их вместе с `SqlDriver`. --- ## 6. LLM-конфигурация (`LlmConfig`) `LlmConfig.fromEnv()` парсит env, валидирует обязательные поля и выбирает бэкенд через `AGENTIK_LLM_BACKEND`: - `openai` (default) — `litert-openai`, текст-онли чат против любого OpenAI-совместимого endpoint. - `google` — `litert-google` (LiteRT-LM 0.17.0, on-device `.task`/`.litertlm` модель, требует нативной библиотеки через `litertlm-jvm`). ### Общие env | env | смысл | default | |---|---|---| | `AGENTIK_PORT` | порт Netty (`/agentik`, `/health`) | `8080` | | `AGENTIK_DB_PATH` | путь к SQLite-файлу | `./agentik.db` | | `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` | | `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» | | `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) | | `AGENTIK_SKILLS_DIR` | папка с навыками (рекурсивно; `SKILL.md` или `*.yaml`/`*.yml`) | не задано (навыков нет) | ### Backend `openai` | env | смысл | |---|---| | `OPENAI_BASE_URL` | endpoint (например, `https://api.openai.com/v1` или `http://localhost:11434/v1`) — обязательно | | `OPENAI_API_KEY` | ключ модели — обязательно | | `OPENAI_MODEL` | имя модели (например, `gpt-4o-mini`, `myopenai/local/codding`) — обязательно | ### Backend `google` (on-device LiteRT-LM) | env | смысл | default | |---|---|---| | `AGENTIK_GOOGLE_MODEL_PATH` | путь к `.task` или `.litertlm` модели — обязательно | | `AGENTIK_GOOGLE_CACHE_DIR` | каталог кеша скомпилированных graph'ов | пусто (системный tmp) | | `AGENTIK_GOOGLE_THREADS` | число CPU-потоков для движка | `4` | `AGENTIK_DB_PATH=:memory:` создаёт in-memory БД (только для тестов и интеграционных проверок). ### Long-lived LiteConversation — ОБЯЗАТЕЛЬНО для обоих бэкендов `LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (LiteRT-LM 0.17.0 умеет правильно восстанавливать state при `systemInstruction + пары User/Assistant` в `initialMessages`). `LiteLlm.capabilities: LiteCapabilities?` (litert-api 7+) — `litert-google` читает из заголовка on-disk модели (text/vision/audio, supportsThinking, supportsFunctionCalling, maxVisionTokenBudget); `litert-openai` возвращает `null` (модель не хранится на диске). Используй для фильтрации модальностей в клиенте. --- ## 7. Расширение ### Подключить тулы (MCP / in-agent) ```kotlin // 1) In-agent tool (нативный LiteTool): val echoTool = object : LiteTool { override fun describe(): String = """{"type":"function","function":{"name":"echo","description":"Echo a string","parameters":{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}}}""" override fun invoke(args: String): String = "echoed: $args" } val tools = listOf(NamedTool("echo", echoTool)) // 2) MCP (stdio / streamable HTTP) — конфиг в Claude Desktop-формате: val mcp = McpConfig.fromEnv() // читает AGENTIK_MCP_CONFIG=path/to/mcp.json val registry = McpRegistry.fromConfig(mcp) // стартует все серверы, лист LiteTool'ов val tools = registry.namedTools // server__tool префикс автоматически // 3) В обоих случаях: val agent = ChatAgent(id, stores, llm, llmConfig, tools = tools) ``` `LiteConversation` принимает `tools = ...` в `LiteConversationConfig`. На каждый `delta.toolCalls` из модели `ChatConversation.runTurn`: 1. Эмитит `Event.ToolCall(callId, toolName, argsJson)` клиенту (по SSE) 2. Записывает `MessageRecord.ToolCall` в audit + working memory (если не temp) 3. Вызывает `tool.invoke(argsJson)` 4. Эмитит `Event.ToolResult(resultId, resultText)` 5. Записывает `MessageRecord.ToolResult` (с `toolCallId = callId`) 6. Кормит `liteConv.addToolResult(callId, name, result)` в LiteConversation (KV-cache выживает между итерациями) 7. Цикл повторяется до `delta.toolCalls.isEmpty()` ID у `ToolCall` и `ToolResult` разные (`tc-…` / `tr-…`), но `MessageRecord.ToolResult.toolCallId` указывает на `MessageRecord.ToolCall.id` той же логической пары. Этим достигается уникальность PK в таблице `message`. ### Навыки (skills) Навыки — это «лениво загружаемые» инструкции: в системный промпт попадают только **имя + краткое описание**, а полный текст модель достаёт сама, вызывая встроенный инструмент `read_skill`. **Формат файла** (два варианта, оба читаются): 1. opencode-style `SKILL.md` — YAML-frontmatter + markdown-тело: ```markdown --- name: backend:spring:db-base description: MUST load before any database work. --- # ... полный текст навыка ... ``` 2. Голый YAML `*.yaml` / `*.yml` — поля `name`, `description`, опционально `body`: ```yaml name: lint description: Run the linter before committing. body: | # Lint Run `./gradlew detekt`. ``` Загрузка: `SkillLoader.loadDirectory(dir)` рекурсивно обходит `AGENTIK_SKILLS_DIR`, парсит файлы и возвращает `SkillCatalog` + список ошибок (битый файл не валит загрузку, дубликат имени — ошибка, выигрывает первый по пути). **Что попадает в системный промпт** (`SkillCatalog.renderSystemPromptSection()`): блок `## Навыки` со списком `- **name**: description`. Тело навыка в промпт НЕ попадает. **Инструмент `read_skill`** (`SkillReadTool`) регистрируется в `ChatAgent` автоматически, если каталог непустой; для модели он выглядит как обычная функция с аргументом `{"name": ""}`. Модель вызывает его по необходимости, результат возвращается как обычный tool-result (см. tool-loop ниже). ```kotlin val skills = SkillLoader.loadDirectory(File(System.getenv("AGENTIK_SKILLS_DIR"))).catalog val agent = ChatAgent(id, stores, llm, llmConfig, tools = mcpRegistry.namedTools, skills = skills) ``` Навык можно передать и напрямую «в тулзах» — `SkillReadTool` достаточно обернуть в `NamedTool(SkillReadTool.NAME, SkillReadTool(catalog))`, но при непустом `skills`-параметре это делается за вас. ### Добавить ещё один транспорт Каждый транспорт — отдельный модуль, который получает `Agent` и сериализует его под свой протокол: * `:server` (HTTP+SSE) — готов, `Route.agentikAgent(agent, path = "/agentik")` * `:client` (HTTP-клиент) — готов, `AgentikAgent(id, baseUrl, httpClient)` * `:irc-server` — IRC-фасад, в планах Транспорт **не имеет доступа к внутренностям `ChatAgent`** — он видит только интерфейс `Agent`. Это и есть «транспортно-агностичное ядро». ### Добавить ещё один LLM-бэкенд 1. Описать `Config` data class с нужными полями. 2. Реализовать `LiteLlm`/`LiteConversation` поверх движка (см. litert-kmp — там уже есть `litert-google`, `litert-openai`, `litert-koog`). 3. Расширить `LlmConfig.createLlm()` веткой `when`. ### Заменить SQLite на Postgres / MongoDB / etc 1. Реализовать три store-интерфейса поверх нового движка. 2. Передать их в `ChatAgent` вместо `SqliteStores`. 3. Удалить (или оставить за `:standalone`-флагом) `:persistence/sqlite/`. --- ## 8. Что НЕ делает `standalone` сегодня * **Нет суммаризации.** `WorkingMemoryStore.compact` уже есть, но без LLM-вызова для генерации текста суммаризации. * **Нет авторизации.** Все эндпоинты открыты. * **Нет инкрементальной догрузки старых сообщений.** `getMessages(after)` работает с offset/limit, но без «схлопывания» (compaction в визуальной истории — задача клиента). Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.