358 lines
23 KiB
Markdown
358 lines
23 KiB
Markdown
# 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<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) |
|
||
| `events(after): Flow<Event>` | 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<MessageRecord>
|
||
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||
```
|
||
|
||
### `ContextStore`
|
||
|
||
```kotlin
|
||
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-вызовом для генерации текста).
|
||
|
||
### `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<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)
|
||
|
||
```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": "<skill>"}`. Модель вызывает его по необходимости, результат возвращается как обычный 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 в визуальной истории — задача клиента).
|
||
|
||
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.
|