23 KiB
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 → online: │
│ StartResponse/AppendText/End; │
│ durable: AssistantMessage │
│ 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
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<AgentEvent> |
live-события по множеству разговоров |
Реализация Conversation:
| метод | смысл |
|---|---|
id: String |
идентификатор диалога |
title: String? |
заголовок (может быть null) |
isTemporal: Boolean |
true = не персистить (temp=true при создании) |
isSupportImageInput/Output: Boolean |
мультимодальные возможности (для v1 оба false) |
updatedAt: Instant |
последний send/rename |
send(content: List<Content>) |
fire-and-forget: добавить user-сообщение, запустить ход, выйти |
interrupt() |
остановить текущий ход (best-effort) |
outbox.conversationEvents(after, id): Flow<Event> |
durable-события (UserMessage, AssistantMessage, ToolCall/Result, Interrupted, Error) — скурсором |
onlineOutbox.onlineEvents(id): Flow<OnlineEvent> |
live-only стриминг (Working, End, StartReasoning, StartResponse, AppendText, AppendImage) |
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 и эмитит durable Event.Error + онлайн OnlineEvent.End. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через getMessages (polling/переподключение): в истории будет Message.Error(id, message, code?), а для этого user-сообщения не будет AssistantMessage. При ошибке стрима живой LiteConversation сбрасывается — следующий send пересоберёт его из working_memory. В working_memory Error не пишется (модель не должна видеть ошибки прошлых ходов).
JournalStore
suspend fun append(record: MessageRecord)
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
suspend fun listAll(conversationId: String): List<MessageRecord>
ContextStore
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
suspend fun list(conversationId: String): List<WorkingMemoryRow>
suspend fun clear(conversationId: String)
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
compact — атомарный «выбросить всё от dropFromOrderIdx и дальше, вставить новую синтетическую запись на следующий order_idx». Для v1 — просто DELETE от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
MutableConversationStore
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<ConversationRecord>
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)
// 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:
- Эмитит
Event.ToolCall(callId, toolName, argsJson)клиенту (по SSE) - Записывает
MessageRecord.ToolCallв audit + working memory (если не temp) - Вызывает
tool.invoke(argsJson) - Эмитит
Event.ToolResult(resultId, resultText) - Записывает
MessageRecord.ToolResult(сtoolCallId = callId) - Кормит
liteConv.addToolResult(callId, name, result)в LiteConversation (KV-cache выживает между итерациями) - Цикл повторяется до
delta.toolCalls.isEmpty()
ID у ToolCall и ToolResult разные (tc-… / tr-…), но MessageRecord.ToolResult.toolCallId указывает на MessageRecord.ToolCall.id той же логической пары. Этим достигается уникальность PK в таблице message.
Навыки (skills)
Навыки — это «лениво загружаемые» инструкции: в системный промпт попадают только имя + краткое описание, а полный текст модель достаёт сама, вызывая встроенный инструмент read_skill.
Формат файла (два варианта, оба читаются):
- opencode-style
SKILL.md— YAML-frontmatter + markdown-тело:--- name: backend:spring:db-base description: MUST load before any database work. --- # ... полный текст навыка ... - Голый YAML
*.yaml/*.yml— поляname,description, опциональноbody: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": "<skill>"}. Модель вызывает его по необходимости, результат возвращается как обычный tool-result (см. tool-loop ниже).
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-бэкенд
- Описать
Configdata class с нужными полями. - Реализовать
LiteLlm/LiteConversationповерх движка (см. litert-kmp — там уже естьlitert-google,litert-openai,litert-koog). - Расширить
LlmConfig.createLlm()веткойwhen.
Заменить SQLite на Postgres / MongoDB / etc
- Реализовать три store-интерфейса поверх нового движка.
- Передать их в
ChatAgentвместоSqliteStores. - Удалить (или оставить за
:standalone-флагом):persistence/sqlite/.
8. Что НЕ делает standalone сегодня
- Нет суммаризации.
WorkingMemoryStore.compactуже есть, но без LLM-вызова для генерации текста суммаризации. - Нет авторизации. Все эндпоинты открыты.
- Нет инкрементальной догрузки старых сообщений.
getMessages(after)работает с offset/limit, но без «схлопывания» (compaction в визуальной истории — задача клиента).
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.