Files
agentik/docs/STANDALONE.md
T
subochev 5f0e0da361 feat(client): client-side caching for conversation list via agent.outbox events
Adds `agent.conversationStore` (read-only view on `conversation` table) to
the :proto Agent interface, plus `agent.renameConversation(id, title?)`
command. Client-side cache in :client is built from a snapshot
(`remote.listFlow(0)` → `local.upsert(...)`) + live updates via
`outbox.agentEvents()` (Created/Deleted/Renamed/Touched).

Changes:
- :journal-api — split `ConversationStore` (read-only: get/list) and
  `MutableConversationStore` (CRUD: upsert/delete/rename/touch);
  `ConversationStore` gained `listFlow` (cold-flow paging via `list`).
- :outbox-api — `AgentEvent.Touched(date, id, updatedAt)` event so
  client cache stays fresh after `send()` (which bumps `updatedAt`).
- :proto.Agent — added `conversationStore: ConversationStore` property,
  added `renameConversation(id, title?): Instant?` command, removed
  `getConversations(offset, limit)` (now: `conversationStore.list(...)`).
- :server — `GET /conversations` now returns `List<ConversationRecord>`
  (lightweight metadata, no handle/image-support flags); `PATCH
  /conversations/{id}` uses `agent.renameConversation` and returns
  the updated `ConversationRecord`.
- :journal-inmemory — expanded targets to jvm+macos+linux+mingw (matches
  :client); moved `InMemoryMutableConversationStore` here from
  :storage-inmemory so :client can use it without pulling ios targets.
- :storage-inmemory — depends on :journal-inmemory.
- :storage-ksqlite — pre-staged rename `KsqliteConversationStore` →
  `KsqliteMutableConversationStore` to match the new interface split.
- :standalone — `ChatAgent` exposes `conversationStore` as a read-only
  view of its `mutableConversationStore`; emits `AgentEvent.Touched`
  after each `send()` (after `conversationStore.touch(id, ts)`).
- :client — new `HttpConversationStore` (read-only HTTP impl);
  `AgentikAgent` wraps the agent with `wrapWithLocalConversationCache`
  so the client sees an in-memory cache (snapshot + outbox events)
  instead of direct HTTP. Cache scope + HttpClient + background job
  all cancelled in `agent.close()`.
- :client/README — new «Кэш списка бесед» section with the
  `listFlow → upsert` / `agentEvents → apply` pattern and a note that
  `conversationStore` is read-only (writes only via Agent commands).

All 96 jvmTest tasks green.
2026-09-22 05:21:01 +03:00

358 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-вызовом для генерации текста).
### `MutableConversationStore`
```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 в визуальной истории — задача клиента).
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.