Files
agentik/docs/STANDALONE.md
T

23 KiB
Raw Blame History

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:

  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-тело:
    ---
    name: backend:spring:db-base
    description: MUST load before any database work.
    ---
    
    # ... полный текст навыка ...
    
  2. Голый 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-бэкенд

  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 в визуальной истории — задача клиента).

Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.