9e5d61707d
Replace EchoProtoAgent / EchoAgent / EchoA2aHandler placeholders with a real
stateful agent on top of SQLite (SQLDelight 2.3.2) and litert-api v6.
persistence (commonMain):
- ConversationStore / MessageStore / WorkingMemoryStore — three narrow
interfaces, all operations suspend, AutoCloseable.
- MessageRecord sealed: UserMessage / AssistantMessage (Body subtype),
ToolCall / ToolResult (audit-only), Summary / System (working-memory-only
synthetic). Snake-case @SerialName discriminators.
- WorkingMemoryEntry sealed: System / User(sourceMessageId) /
Assistant(sourceMessageId); sourceMessageId is null for System.
- Two-table dual-log model: append-only message audit + mutable
working_memory with monotonic order_idx.
SQLite (jvmMain):
- SQLDelight schema + SqliteConversationStore / SqliteMessageStore /
SqliteWorkingMemoryStore under src/jvmMain/sqldelight/.
- SqliteStores.open(path) / inMemory(); Schema.create gated on
sqlite_master probe for idempotency.
- All payload_json is the MessageRecord encoded as JSON; subtype-specific
fields avoid migrations.
agent (jvmMain):
- ChatAgent — stateful proto.Agent with live in-memory cache, lock-protected,
AgentEvent bus (Created/Deleted).
- ChatConversation — long-lived LiteConversation handle; created lazily on
first send from working_memory (system + initial messages), reused across
all subsequent turns (REQUIRED for litert-google KV-cache).
- Per turn: append User to audit + WM → sendStreamContents (wrapped in
transformWhile for litert-google-jvm 0.16.1 isDone workaround) → emit
AppendText deltas → append Assistant to audit + WM + touch conversation.
- isClosed flag so getConversation reconstructs after close.
llm (jvmMain):
- LlmConfig data class with LlmBackend enum (OPENAI / GOOGLE); fromEnv
parses AGENTIK_LLM_BACKEND and dispatches to backend-specific config.
- OpenAI: litert-openai, OpenAI-compatible endpoint, validated
baseUrl/apiKey/model.
- Google: litert-google (reflection-resolved pw.binom.litert.google
factory) on top of litertlm-jvm 0.16.1 native engine;
visionBackend/audioBackend = null (LiteRT-LM 0.16.1 binds encoder
graph even with null backend, but a model lacking encoder crashes;
null is the correct "don't bind" signal).
- foldSystemIntoFirstUser (default true for GOOGLE) folds system prompt
into the first user message to avoid chat template alternation issues.
build:
- Add sqldelight plugin + runtime + sqlite-driver + coroutines-extensions
to gradle/libs.versions.toml.
- litert-openai: implementation; litert-google: runtimeOnly (resolved via
reflection at runtime).
- KMP jvm executable via @OptIn(ExperimentalKotlinGradlePluginApi) +
jvm { binaries { executable { mainClass.set("...MainKt") } } }.
tests (jvmTest): 30 passing
- PersistenceTest (11): conversation upsert/list/cascade-delete/rename/
touch; message audit append/list; working-memory order preservation;
image-content payload roundtrip.
- ChatAgentTest (14): system-prompt seeding; persistent vs temp
persistence across SqliteStores reopen; multi-turn audit + WM growth;
interrupt of in-flight slow send; agentEvents Created/Deleted flow;
closed-conv reconstruct via getConversation.
- LlmConfigTest (6): env happy path, defaults, missing fields throw.
smoke tested e2e:
- openai backend against real llm.binom.pw/v1 (myopenai/local/codding)
— multi-turn dialogue persisted, kill -9 + restart survives.
- google backend against gemma-4-E2B-it.litertlm — multi-turn
("Hello there!" → "2 + 2 = 4"), KV-cache survives across turns,
SSE start→append_text*→end cleanly closes.
docs/STANDALONE.md updated for v1 architecture, dual-backend env table,
long-lived LiteConversation invariant, and litert-google-jvm 0.16.1
isDone-stream workaround.
297 lines
18 KiB
Markdown
297 lines
18 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.16.1 через нативную `.so`-библиотеку).
|
||
|
||
Этот документ описывает, как `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")
|
||
|
||
: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 сообщения. Никогда не редактируется (кроме каскадного `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`.
|
||
|
||
### `MessageStore`
|
||
|
||
```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>
|
||
```
|
||
|
||
### `WorkingMemoryStore`
|
||
|
||
```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.16.1, 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` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» |
|
||
|
||
### 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 — ОБЯЗАТЕЛЬНО для google
|
||
|
||
`LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. На v1 `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (и в v1 приводит к ошибке chat template «roles must alternate» после двух user-сообщений).
|
||
|
||
`AGENTIK_GOOGLE_FOLD_SYSTEM_INTO_FIRST_USER` (default `true` для google) — фолдит системный промпт в первое user-сообщение, чтобы движок увидел только `[User+system, Assistant, User, ...]` и не падал на alternation. Для openai этот режим отключён.
|
||
|
||
### Workaround для litert-google-jvm 0.16.1
|
||
|
||
Баг в `sendStreamContents` — поток не закрывается после `isDone = true`. `ChatConversation` оборачивает стрим в `transformWhile { !delta.isDone }`, поэтому SSE получает корректный `End` event.
|
||
|
||
---
|
||
|
||
## 7. Расширение
|
||
|
||
### Подключить тул (v2)
|
||
|
||
```kotlin
|
||
val tools: List<LiteTool> = listOf(ReadFileTool(Path.of("/work")))
|
||
val cfg = LiteConversationConfig(systemInstruction = "…", tools = tools)
|
||
```
|
||
|
||
LiteConversation сообщит модели о доступных тулах. После появления `LiteContentPart.ToolResult` (v2) появится и `ChatConversation.addToolResult(...)` — ручной tool-loop на on-device движке без пересоздания беседы.
|
||
|
||
### Добавить ещё один транспорт
|
||
|
||
Каждый транспорт — отдельный модуль, который получает `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` сегодня
|
||
|
||
* **Нет инструментов (тулов).** Поддержка ждёт `LiteContentPart.ToolResult` в litert-api (v2). До тех пор агент — text-only чат.
|
||
* **Нет суммаризации.** `WorkingMemoryStore.compact` уже есть, но без LLM-вызова для генерации текста суммаризации.
|
||
* **Нет MCP-клиента.**
|
||
* **Нет авторизации.** Все эндпоинты открыты.
|
||
* **Нет инкрементальной догрузки старых сообщений.** `getMessages(after)` работает с offset/limit, но без «схлопывания» (compaction в визуальной истории — задача клиента).
|
||
|
||
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.
|