Files
agentik/docs/STANDALONE.md
T
subochev adcb8f54d6 skills: каталог + ленивая загрузка read_skill
Пользовательские инструкции («навыки») живут в указанной папке
(AGENTIK_SKILLS_DIR), рекурсивно читаются при старте и попадают в
системный промпт в сжатом виде: только имя + краткое описание.
Полный текст модель подгружает по требованию, вызывая встроенный
инструмент read_skill(name).

*:skills
  - SkillCatalog + SkillPrompt (commonMain): рендер секции системного
    промпта; тело навыка в промпт не течёт.
  - SkillParser.parseAuto(): теперь читает и opencode-стиль SKILL.md
    (YAML frontmatter + markdown тело), и голый *.yaml/*.yml
    (поля name, description, опц. body). parseOrThrow для strict-путей.
  - SkillParseError.render(): человекочитаемое описание ошибки для
    логов и диагностики.
  - SkillLoader (jvmMain): рекурсивный обход папки, детерминированный
    порядок (по пути), ошибки отдельных файлов не валят загрузку;
    дубликаты имён → ошибка, выигрывает первый по пути.

* :standalone
  - AgentikConfig.skillsDir + env AGENTIK_SKILLS_DIR.
  - ChatAgent: параметр skills (SkillCatalog); системный промпт
    автоматически дополняется секцией «## Навыки» и в working memory
    сидится вместе с базовым промптом.
  - При непустом каталоге в tools автоматически добавляется
    SkillReadTool (имя read_skill) — модель может загрузить полный
    текст навыка, как обычный LiteTool.
  - Main.kt: загружает навыки и шумно логирует ошибки загрузки в stderr.

* docs
  - STANDALONE.md: секция «Навыки (skills)», env-переменная в таблице.
  - Формат SKILL.md (opencode frontmatter) + голый *.yaml/*.yml.

Тесты: :skills jvmTest 34, :standalone jvmTest 69 (новые — состав
системного промпта, регистрация read_skill, навыки не утекают в
промпт телом).
2026-09-14 00:25:22 +03:00

356 lines
22 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 сообщения. Никогда не редактируется (кроме каскадного `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.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":"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 в визуальной истории — задача клиента).
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.