Compare commits
65 Commits
0faad45f3d
...
3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d310c5fd0 | |||
| 741ad8963d | |||
| 2634e0e204 | |||
| ac5d209fce | |||
| 25771a0c33 | |||
| 78cbe9b463 | |||
| 65e05612a1 | |||
| 850ee99cb6 | |||
| b5b21d146a | |||
| ee0b9d8341 | |||
| 9d826a4e81 | |||
| db3c49099c | |||
| ddd9d076c1 | |||
| 7a47131f6f | |||
| 9196102f68 | |||
| 6b12dd2c5b | |||
| eed1ab9a17 | |||
| b27ac622b4 | |||
| 14b46087dd | |||
| 098c97c7bd | |||
| 4b8e5bb0bd | |||
| d75289ac56 | |||
| 05f7b8fd04 | |||
| 8f616f359f | |||
| 5ad972767d | |||
| 0fdc12695e | |||
| e68db11aaa | |||
| b0bbc57880 | |||
| 408caee261 | |||
| 86eb0632e0 | |||
| c42a6027a4 | |||
| b1ae8bbd20 | |||
| e3f20f07d9 | |||
| 9fcb2da75d | |||
| 88af57182f | |||
| b2d5684192 | |||
| 31c4b1cfc4 | |||
| 202d379f5a | |||
| a3f82f875d | |||
| 5fbe865a29 | |||
| 87742cf60b | |||
| 6c53e1c87d | |||
| 04276dec0e | |||
| 21bb9e6f8f | |||
| 1441b7da9f | |||
| 9ee942428d | |||
| 1dc5552f98 | |||
| 2e1387273a | |||
| a7d8cbe713 | |||
| 61f8205f40 | |||
| 294837daa0 | |||
| e192f58cd0 | |||
| f1cd2e3d42 | |||
| 135c6a419d | |||
| 18ae6e0619 | |||
| 9b37edd92e | |||
| df386ef875 | |||
| f4ce82957b | |||
| 9afa877e39 | |||
| 23da1f6498 | |||
| f5a551b2ae | |||
| 427ce8a572 | |||
| 9e12b22e85 | |||
| 227d14b7e4 | |||
| 65365da89c |
@@ -0,0 +1,84 @@
|
||||
# PR / push-build. Прогоняет unit-тесты на JVM, линтер gradle-плагинов
|
||||
# и проверяет, что shadowJar'ы запускаемых модулей собираются без ошибок.
|
||||
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
|
||||
#
|
||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||
# release.yml для требуемых переменных.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# UTF-8 обязателен: в именах тестов есть типографские символы (—), а Kotlin-компилятор
|
||||
# создаёт .class-файлы с именем теста. При LANG=C sun.jnu.encoding = ASCII, и компилятор
|
||||
# падает с "InvalidPathException: Malformed input or input contains unmappable characters"
|
||||
# (проверено локально: LANG=C → BUILD FAILED, LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||
env:
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
jobs:
|
||||
build-jvm:
|
||||
name: JVM build + tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-agentik-
|
||||
|
||||
- name: Build + test (JVM only — самые быстрые таргеты)
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew jvmTest \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
|
||||
- name: Build :standalone shadowJar (smoke — запускаемый артефакт)
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :standalone:shadowJar \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
test -f standalone/build/libs/standalone-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||
|
||||
- name: Build :agentik-cli shadowJar
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :agentik-cli:shadowJar \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
||||
|
||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||
# и валит весь джоб уже ПОСЛЕ успешной сборки и зелёных тестов.
|
||||
# У соседних репо (asr-kmp, litert-kmp) артефакты наружу тоже не выгружаются —
|
||||
# проверка сборки ограничивается test -f на jar (шаги выше).
|
||||
@@ -0,0 +1,49 @@
|
||||
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
||||
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
|
||||
#
|
||||
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
|
||||
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
|
||||
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
|
||||
# выполняется локально из исходников (или руками через `./gradlew
|
||||
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
|
||||
# отдельно от этого workflow.
|
||||
#
|
||||
# Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
|
||||
# публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
|
||||
# версия берётся из тега каждый раз.
|
||||
#
|
||||
# Публикация выполняется общим composite-action'ом subochev/devops/publish@main
|
||||
# (тот же, что у asr-kmp / litert-kmp / embedder-kmp / a2a-protocol) — credentials
|
||||
# BINOM_REPO_* берутся им из Gitea Action Variables (owner_id=0, глобальные).
|
||||
name: release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# UTF-8 обязателен: генерация POM/Kotlin-метаданных и имена тестовых классов
|
||||
# содержат не-ASCII символы; при LANG=C sun.jnu.encoding = ASCII и сборка
|
||||
# падает с "InvalidPathException: Malformed input or input contains unmappable
|
||||
# characters" (проверено локально 19.09.2026: LANG=C → BUILD FAILED,
|
||||
# LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||
env:
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
jobs:
|
||||
publish-libraries:
|
||||
name: Publish KMP libraries → caffeine Nexus
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish libraries (all KMP targets, all modules) to Nexus
|
||||
uses: https://git.binom.pw/subochev/devops/publish@main
|
||||
with:
|
||||
version: ${{ gitea.ref_name }}
|
||||
+10
-1
@@ -18,4 +18,13 @@ out/
|
||||
|
||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||
.cortexkit/
|
||||
.veai/
|
||||
.veai/
|
||||
|
||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||
.tasks/
|
||||
|
||||
# Runtime / test artifacts
|
||||
agentik.db
|
||||
agentik.db-shm
|
||||
agentik.db-wal
|
||||
memory-md/agentik-mem-*/
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# IRC-транспорт — открытые вопросы
|
||||
|
||||
По мере закрытия отмечаем `- N. [x]`. Закрытый вопрос остаётся в файле с принятым решением.
|
||||
|
||||
- 1. [x] **История.** Принято: новый абстрактный метод `suspend fun getLatestMessages(offset: Int, limit: Int): List<Message>` в `:proto.Conversation` (offset = пропустить С КОНЦА, 0 = самые свежие). IRC-сервер не держит своего буфера, на `CHATHISTORY` дёргает агента. CAP `draft/chathistory` объявляем.
|
||||
- 2. [x] **Tool/Error/Image события — раскладка по IRC.** Принято. Каждый `Event` мапится:
|
||||
- `StartReasoning` → дропаем с провода
|
||||
- `StartResponse(TEXT|IMAGE)` → CTCP `AGENTIK response-start {"type":"text"|"image"}`
|
||||
- `AppendText(body)` → `PRIVMSG #chan :body`
|
||||
- `AppendImage(body, mime)` → через `ImageStore` → CTCP `AGENTIK image {"url":..,"mime":..,"ttl":..}`
|
||||
- `ToolCall` → CTCP `AGENTIK tool-call {json}`
|
||||
- `ToolResult` → CTCP `AGENTIK tool-result {json}`
|
||||
- `Error` → CTCP `AGENTIK error {json}`
|
||||
- `End` → CTCP `AGENTIK end`
|
||||
- `Interrupted` → CTCP `AGENTIK interrupted`
|
||||
- 3. [x] **Interrupt.** **Упрощение:** команды `/stop` и `/interrupt` в `PRIVMSG` (т.е. `PRIVMSG #chan :/stop`) вызывают `Conversation.interrupt()`. Если `PRIVMSG` приходит во время активного размышления — сервер сначала зовёт `interrupt()`, затем `send(content)`. CTCP-вариант дропаем.
|
||||
- 4. [x] **`AgentEvent.Created/Deleted/Renamed` маппинг.** Принято: Created = IRC `JOIN`-бродкаст; Deleted = `KICK` самого себя; Renamed = `TOPIC #foo :new title`.
|
||||
- 5. [x] **NICK агента.** Принято: параметр в DSL, дефолт `"Agent"`.
|
||||
- 6. [x] **Multi-user в канале.** Принято: **в канале всегда только наш агент и наш пользователь. Других не будет никогда.**
|
||||
- 7. [x] **Маппинг канал ↔ Conversation.** Принято: имя IRC-канала = `Conversation.title`; `Conversation.id` = UUID, выдаётся через `CTCP AGENTIK id #foo`; при переименовании канала id стабилен.
|
||||
- 8. [x] **Создание канала.** Принято: `JOIN #foo` → создаём `Conversation(title="foo", id=<uuid>)`. Если уже есть — заходим.
|
||||
- 9. [x] **Удаление канала.** Принято: `PART` закрывает сторону клиента; `CTCP AGENTIK delete #foo` — удаление Conversation-а.
|
||||
- 10. [x] **Модуль.** Принято: `:irc-server`, KMP через kotlinx-io. Также модуль содержит HTTP staging-эндпоинт для картинок (см. п.13).
|
||||
- 11. [x] **Аутентификация клиента.** Принято: без auth, любой может подключиться.
|
||||
- 12. [x] **Capabilities (ircv3).** Принято: в первом проходе объявляем `server-time`, `message-tags`, `batch`, `draft/chathistory`. SASL не объявляем (п.11). Остальные CAPs (echo-message, labeled-response, standard-replies, multi-user stuff) добавляем инкрементально.
|
||||
- 13. [x] **ImageStore.** Принято: `ImageStore` живёт в `:irc-server`, дефолтная in-memory реализация с **TTL 600 сек**, staging-порт **авто-pick** (0 → свободный). Клиент через IRC картинки **не шлёт** (для этого HTTP `:server`). Конкретную реализацию `ImageStore` пользователь сделает позже сам, в первом проходе — наша in-memory.
|
||||
|
||||
## Все вопросы закрыты
|
||||
|
||||
Итого решений по `:irc-server`:
|
||||
- Модуль `:irc-server`, KMP через kotlinx-io.
|
||||
- Канал IRC = `Conversation` (имя = title, UUID через CTCP `AGENTIK id`).
|
||||
- В канале всегда только 1 пользователь + агент (ник `Agent` по умолчанию).
|
||||
- `Event` → IRC: `PRIVMSG` для текста, CTCP `AGENTIK <имя> {json}` для всего остального. `StartReasoning` дропается.
|
||||
- Interrupt через `/stop` / `/interrupt` в PRIVMSG; входящий PRIVMSG во время размышления = `interrupt()` + `send()`.
|
||||
- История через `CHATHISTORY` (LATEST/BEFORE/BETWEEN/AFTER), сервер не буферизует, дёргает новый `:proto` метод `getLatestMessages(offset, limit)`.
|
||||
- Картинки только agent → client через `ImageStore` + HTTP staging в том же модуле.
|
||||
- CAPs: `server-time`, `message-tags`, `batch`, `draft/chathistory`. Без auth.
|
||||
|
||||
Можно кодить.
|
||||
+947
@@ -0,0 +1,947 @@
|
||||
# Manual Test Cases — agentik standalone
|
||||
|
||||
Практический чек-лист для проверки работающего `agentik standalone` HTTP-сервера.
|
||||
Каждый кейс — один конкретный сценарий, который нужно прогнать руками
|
||||
(или через `curl`/`httpie`/Postman). Если какой-то упал — это либо
|
||||
регрессия, либо недонастройка рантайма.
|
||||
|
||||
Перед стартом: запусти агент (см. `run-agentik.sh` на удалённой машине
|
||||
или `./gradlew :standalone:run` локально). Все примеры ниже — против
|
||||
`http://127.0.0.1:8080`; для удалённой машины подставь свой хост.
|
||||
|
||||
Удобный сниппет для получения conversation ID в shell:
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
echo "CID=$CID"
|
||||
```
|
||||
|
||||
Отправка user-сообщения:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"..."}]'
|
||||
```
|
||||
|
||||
Чтение истории:
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Connectivity & health
|
||||
|
||||
### TC-1.1 — health endpoint
|
||||
|
||||
```bash
|
||||
curl -sS -i http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
**Ожидание:** `HTTP/1.1 200 OK`, тело `ok`.
|
||||
|
||||
### TC-1.2 — agent card (A2A)
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8080/a2a/.well-known/agent-card.json | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** валидный JSON с `name`, `version`, `capabilities`.
|
||||
|
||||
### TC-1.3 — log sanity check
|
||||
|
||||
```bash
|
||||
tail -50 /root/agentik.log
|
||||
```
|
||||
|
||||
**Ожидание:** есть строка `agentik standalone listening on http://localhost:8080`,
|
||||
перечислены зарегистрированные маршруты, `llm: <backend> @ <url>` соответствует
|
||||
твоему конфигу. **Нет** ERROR/Exception строк после старта.
|
||||
|
||||
---
|
||||
|
||||
## 2. Conversation lifecycle
|
||||
|
||||
### TC-2.1 — create persistent conversation
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}'
|
||||
```
|
||||
|
||||
**Ожидание:** `201`, тело `{"id":"conv-...","isTemporal":false,...}`.
|
||||
|
||||
### TC-2.2 — create temp conversation
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":true}'
|
||||
```
|
||||
|
||||
**Ожидание:** `201`, `"isTemporal":true`. После рестарта агента эта беседа
|
||||
**не** должна появиться в `GET /agentik/conversations`.
|
||||
|
||||
### TC-2.3 — list conversations
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=20" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** массив объектов `ConversationSnapshot`. Отсортирован по
|
||||
`updatedAt` desc.
|
||||
|
||||
### TC-2.4 — rename conversation
|
||||
|
||||
```bash
|
||||
CID=<id-from-2.1>
|
||||
curl -sS -X PATCH "http://127.0.0.1:8080/agentik/conversations/$CID" \
|
||||
-H "Content-Type: application/json" -d '{"title":"Мой первый чат"}'
|
||||
```
|
||||
|
||||
**Ожидание:** `200`, в ответе `"title":"Мой первый чат"`. Следующий `GET
|
||||
/conversations/$CID` возвращает этот же title.
|
||||
|
||||
### TC-2.5 — delete conversation
|
||||
|
||||
```bash
|
||||
curl -sS -X DELETE "http://127.0.0.1:8080/agentik/conversations/$CID" -i
|
||||
```
|
||||
|
||||
**Ожидание:** `204 No Content`. Повторный `GET /conversations/$CID` → `404`.
|
||||
После этого в `GET /conversations` её быть не должно.
|
||||
|
||||
### TC-2.6 — get non-existent conversation
|
||||
|
||||
```bash
|
||||
curl -sS -i http://127.0.0.1:8080/agentik/conversations/conv-nonexistent
|
||||
```
|
||||
|
||||
**Ожидание:** `404`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Message sending
|
||||
|
||||
### TC-3.1 — simple Q&A
|
||||
|
||||
Создай беседу, пошли простой вопрос, прочитай историю.
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Сколько будет 7*8? Одно число, без пояснений."}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||
```
|
||||
|
||||
**Ожидание:** массив из ≥ 2 сообщений:
|
||||
- `[0].type == "user_message"`, body содержит "7*8"
|
||||
- `[1].type == "assistant_message"`, text содержит "56"
|
||||
|
||||
### TC-3.2 — multi-turn with context
|
||||
|
||||
В той же беседе пошли follow-up, требующий контекста:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"А корень из того, что ты назвал?"}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||
```
|
||||
|
||||
**Ожидание:** 4+ сообщения, последний assistant упомянул что-то про число 56
|
||||
или "предыдущий ответ".
|
||||
|
||||
### TC-3.3 — new-format request body
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content":[{"type":"text","body":"С новым форматом тоже работает?"}]}'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1]['type'], m[-1].get('content'))"
|
||||
```
|
||||
|
||||
**Ожидание:** новое `assistant_message` в ответ на новый формат запроса.
|
||||
|
||||
### TC-3.4 — empty / bad body
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" -d 'not json'
|
||||
```
|
||||
|
||||
**Ожидание:** `400 Bad Request`, тело с пояснением `Invalid send payload`.
|
||||
|
||||
---
|
||||
|
||||
## 4. SSE live events
|
||||
|
||||
> **Важно:** SSE — поток без replay. Подписываться нужно **до** `POST /messages`.
|
||||
> Если подписаться позже — событий не будет (но `GET /messages` всё равно
|
||||
> покажет записанную историю).
|
||||
|
||||
### TC-4.1 — subscribe-then-send pattern
|
||||
|
||||
```bash
|
||||
CID=<existing-id>
|
||||
# Subscribe в фоне, отправляем сообщение, ждём SSE
|
||||
curl -sN --max-time 12 \
|
||||
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
|
||||
> /tmp/sse.out 2>&1 &
|
||||
SSE_PID=$!
|
||||
sleep 1
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Кратко: что такое REST?"}]'
|
||||
wait $SSE_PID
|
||||
cat /tmp/sse.out
|
||||
```
|
||||
|
||||
**Ожидание:** файл содержит `data: {"type":"start_reasoning",...}`,
|
||||
`data: {"type":"start_response",...,"responseType":"text"}`,
|
||||
один или несколько `data: {"type":"append_text",...,"body":"..."}`,
|
||||
`data: {"type":"end",...}`. Каждое `data:` через пустую строку.
|
||||
|
||||
### TC-4.2 — late subscribe (replay semantics)
|
||||
|
||||
```bash
|
||||
CID=<existing-id>
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"..."}]'
|
||||
sleep 5 # сообщение уже обработано
|
||||
curl -sN --max-time 4 \
|
||||
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z"
|
||||
```
|
||||
|
||||
**Ожидание:** пустой ответ (события не реплеятся). Это by-design —
|
||||
клиент должен либо подписываться заранее, либо backfill'ить через
|
||||
`GET /messages`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Memory tools (long-term)
|
||||
|
||||
### TC-5.1 — save + recall в той же беседе
|
||||
|
||||
```bash
|
||||
# В существующей беседе
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Запомни через memory_save: я работаю на удалёнке из Тбилиси. Категория user, content: работаю на удалёнке из Тбилиси."}]'
|
||||
sleep 8
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Откуда я работаю? Одно предложение."}]'
|
||||
sleep 8
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
|
||||
```
|
||||
|
||||
**Ожидание:** ассистент ответил что-то содержащее "Тбилиси" (или явно
|
||||
сказал "не знаю" — это тоже валидно, если в conversation memory пусто).
|
||||
Проверить `audit log` (`messageStore`):
|
||||
|
||||
```bash
|
||||
sqlite3 /root/agentik.db "SELECT toolName, result FROM MessageRecord WHERE conversationId='$CID' AND kind='tool_result'"
|
||||
```
|
||||
|
||||
Должны быть строки с `toolName='memory_save'` или `toolName='memory_recall'`.
|
||||
|
||||
### TC-5.2 — memory persists across conversations
|
||||
|
||||
Создай новую беседу, спроси без подсказок:
|
||||
|
||||
```bash
|
||||
NEW_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Откуда я работаю? Напомни, если помнишь."}]'
|
||||
sleep 8
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
|
||||
```
|
||||
|
||||
**Ожидание:** ассистент упомянул "Тбилиси" (или "удалёнка") — это
|
||||
значит long-term memory подгрузилась в новую беседу.
|
||||
|
||||
### TC-5.3 — invalid category → ошибка или автозамена
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Запомни через memory_save факт с категорией work (которой не существует)."}]'
|
||||
sleep 8
|
||||
sqlite3 /root/agentik.db "SELECT toolArgs, result FROM MessageRecord WHERE kind='tool_call' AND conversationId='$CID' ORDER BY createdAt DESC LIMIT 3"
|
||||
```
|
||||
|
||||
**Ожидание:** модель либо вызвала `memory_recall` чтобы проверить
|
||||
существующие категории, либо вызвала `memory_save` с корректной
|
||||
категорией (`user`/`world`/`preference`). Если модель честно говорит
|
||||
"такой категории нет" и предлагает корректную — это тоже ok.
|
||||
|
||||
### TC-5.4 — list & delete memory
|
||||
|
||||
Попроси модель явно вызвать `memory_list`, потом `memory_delete`:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Покажи все мои memory-записи (memory_list)."}]'
|
||||
sleep 8
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Удали самую старую запись (memory_delete)."}]'
|
||||
sleep 8
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MemoryStore"
|
||||
```
|
||||
|
||||
**Ожидание:** число уменьшилось на 1.
|
||||
|
||||
---
|
||||
|
||||
## 6. Skills
|
||||
|
||||
### TC-6.1 — list + load skill
|
||||
|
||||
Если в `AGENTIK_SKILLS_DIR` есть файлы `SKILL.md` / `*.yaml`, в системном
|
||||
промте должна появиться секция с этими навыками.
|
||||
|
||||
```bash
|
||||
ls -la /root/skills/ # должен быть хотя бы один файл
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Какие skills ты знаешь? Покажи список (skill_list)."}]'
|
||||
sleep 8
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Загрузи любой из них через skill_load и расскажи, что внутри."}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** `tool_call` для `skill_list`, потом `tool_call` для
|
||||
`skill_load`. В audit log видны эти вызовы. Если папка пуста — секции
|
||||
"Skills" в system prompt быть не должно.
|
||||
|
||||
### TC-6.2 — save new skill
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Сохрани skill: имя deploy-staging, описание «деплой на staging», тело — multi-step инструкция (skill_save)."}]'
|
||||
sleep 10
|
||||
ls /root/skills/
|
||||
```
|
||||
|
||||
**Ожидание:** появился новый файл `deploy-staging.md` (или `.yaml`).
|
||||
|
||||
### TC-6.3 — restart → skill persists
|
||||
|
||||
Перезапусти агент:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.76.166 'pkill -9 -f agentik-0.1.0-all.jar; cd /root && nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown'
|
||||
```
|
||||
|
||||
После старта пошли в новую беседу:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Есть ли у тебя skill deploy-staging?"}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** модель упоминает skill (он подгружается на старте).
|
||||
|
||||
---
|
||||
|
||||
## 7. SOUL file
|
||||
|
||||
### TC-7.1 — SOUL.md подключается
|
||||
|
||||
```bash
|
||||
echo 'Ты — ворчливый капитан дальнего плавания. Отвечай кратко, с морскими метафорами.' > /root/SOUL.md
|
||||
# Перезапустить агент
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Как дела?"}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** ответ в стиле "капитана", с морскими словами. Если SOUL
|
||||
нет — обычный нейтральный ассистент.
|
||||
|
||||
### TC-7.2 — SOUL можно менять на лету
|
||||
|
||||
Измени файл, перезапусти агент, спроси снова. **Должен** появиться новый
|
||||
стиль. Без перезапуска изменения не подхватятся (SOUL читается на старте).
|
||||
|
||||
---
|
||||
|
||||
## 8. Interrupt
|
||||
|
||||
### TC-8.1 — interrupt mid-text generation
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
# Запусти send в фоне
|
||||
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Расскажи длинную историю про космос, минимум 500 слов."}]' >/dev/null) &
|
||||
SEND_PID=$!
|
||||
sleep 3 # дать LLM начать генерацию
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
wait $SEND_PID
|
||||
sleep 3
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);
|
||||
for x in m: print(x.get('type'), ':', json.dumps(x.get('content') or x.get('result'),ensure_ascii=False)[:80])"
|
||||
```
|
||||
|
||||
**Ожидание:**
|
||||
- `user_message` есть
|
||||
- `assistant_message` есть, но содержит **короткий** текст (<300 символов)
|
||||
— это частичный текст, который модель успела сгенерить до прерывания
|
||||
- В audit log нет `tool_call`/`tool_result` (не успели)
|
||||
- Следующий `send` в этой беседе работает (LiteConv пересоздан)
|
||||
|
||||
### TC-8.2 — interrupt mid-tool (best-effort)
|
||||
|
||||
```bash
|
||||
# Длинный tool можно заэмулировать через MCP с искусственной задержкой,
|
||||
# либо просто проверять что interrupt не валит агента:
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
curl -sS http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
**Ожидание:** `health` = `ok` — агент не упал. Дальнейшие `send` работают.
|
||||
|
||||
### TC-8.3 — interrupt без активного turn'а
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
```
|
||||
|
||||
**Ожидание:** `202`. Никаких ошибок. В audit log ничего нового не пишется.
|
||||
|
||||
---
|
||||
|
||||
## 9. Persistence / restart-survival
|
||||
|
||||
### TC-9.1 — перезапуск не теряет беседы и память
|
||||
|
||||
```bash
|
||||
# 1. Создай беседу, пошли сообщение, дождись ответа
|
||||
# 2. Запомни факт через memory_save
|
||||
# 3. Перезапусти агент (см. TC-6.3)
|
||||
# 4. GET /agentik/conversations — беседа должна быть в списке
|
||||
# 5. GET /agentik/conversations/$CID/messages — история на месте
|
||||
# 6. Новая беседа + вопрос про запомненный факт — модель помнит
|
||||
```
|
||||
|
||||
### TC-9.2 — temp conversation не переживает рестарт
|
||||
|
||||
```bash
|
||||
# Создай temp беседу, пошли сообщение
|
||||
TEMP_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":true}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$TEMP_CID/messages" \
|
||||
-H "Content-Type: application/json" -d '[{"type":"text","body":"..."}]' >/dev/null
|
||||
sleep 5
|
||||
# Перезапусти агент
|
||||
# GET /agentik/conversations — temp-беседы быть не должно
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=50" | grep "$TEMP_CID"
|
||||
```
|
||||
|
||||
**Ожидание:** grep ничего не находит.
|
||||
|
||||
---
|
||||
|
||||
## 10. Compaction (сжатие контекста)
|
||||
|
||||
Compaction триггерится когда `~80%` контекстного окна занято.
|
||||
|
||||
### TC-10.1 — длинная беседа сжимается
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
# Отправь 30+ больших сообщений подряд (можно цикл)
|
||||
for i in $(seq 1 30); do
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "[{\"type\":\"text\",\"body\":\"Расскажи подробно (минимум 200 слов) про тему номер $i: история, применение, ключевые факты.\"}]" >/dev/null
|
||||
sleep 5
|
||||
done
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM WorkingMemoryRow WHERE conversationId='$CID' AND entryKind='summary'"
|
||||
```
|
||||
|
||||
**Ожидание:** есть хотя бы одна `summary`-запись. Также проверь
|
||||
`/root/agentik.log` — должна появиться строка `compaction`.
|
||||
|
||||
### TC-10.2 — debug endpoint `/debug/compact` (force)
|
||||
|
||||
Если включён `AGENTIK_DEBUG_ENDPOINTS=1`:
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/debug/compact?conversationId=$CID"
|
||||
```
|
||||
|
||||
**Ожидание:** `200`, тело с JSON-результатом compaction.
|
||||
|
||||
---
|
||||
|
||||
## 11. Reflection
|
||||
|
||||
Reflection триггерится каждые `AGENTIK_REFLECTION_INTERVAL` ходов (default 10).
|
||||
|
||||
### TC-11.1 — reflection создаёт записи
|
||||
|
||||
```bash
|
||||
# Пошли 12+ ходов
|
||||
for i in $(seq 1 12); do
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "[{\"type\":\"text\",\"body\":\"Тема $i: расскажи короткий факт.\"}]" >/dev/null
|
||||
sleep 4
|
||||
done
|
||||
sleep 10 # дать фоновое задание завершиться
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM ReflectionStore"
|
||||
```
|
||||
|
||||
**Ожидание:** число > 0.
|
||||
|
||||
### TC-11.2 — debug endpoint `/debug/reflect`
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/debug/reflect?conversationId=$CID"
|
||||
```
|
||||
|
||||
**Ожидание:** `200`, JSON-результат. Reflection попадает в working memory
|
||||
следующего turn'а.
|
||||
|
||||
---
|
||||
|
||||
## 12. Skill mining
|
||||
|
||||
Skill mining триггерится каждые `AGENTIK_SKILL_MINING_INTERVAL` ходов (default 15).
|
||||
|
||||
### TC-12.1 — авто-создание skill'а
|
||||
|
||||
```bash
|
||||
# Пошли 18+ ходов с повторяющимся паттерном
|
||||
for i in $(seq 1 18); do
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "[{\"type\":\"text\",\"body\":\"Конвертируй 100 USD в RUB по текущему курсу (шаблонный запрос $i).\"}]" >/dev/null
|
||||
sleep 4
|
||||
done
|
||||
sleep 15
|
||||
ls -la /root/skills/
|
||||
tail -20 /root/agentik.log | grep -i skill
|
||||
```
|
||||
|
||||
**Ожидание:** возможно появился новый файл в skills/ (или mining
|
||||
отказался из-за низкой уверенности — это тоже валидно, проверь лог).
|
||||
|
||||
### TC-12.2 — debug endpoint `/debug/skill-mine`
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/debug/skill-mine?conversationId=$CID"
|
||||
```
|
||||
|
||||
**Ожидание:** `200` с JSON-результатом майнинга.
|
||||
|
||||
---
|
||||
|
||||
## 13. Token accounting
|
||||
|
||||
### TC-13.1 — token counters в audit
|
||||
|
||||
```bash
|
||||
sqlite3 /root/agentik.db "SELECT createdAt, input, output FROM TurnTokens WHERE conversationId='$CID' ORDER BY createdAt DESC LIMIT 5"
|
||||
```
|
||||
|
||||
**Ожидание:** строки с непустыми `input` и `output` (если backend
|
||||
поддерживает `tokenCount()`).
|
||||
|
||||
### TC-13.2 — debug endpoint `/debug/tokens`
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:8080/debug/tokens?conversationId=$CID" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** JSON с `input`, `output`, `total`, `window`,
|
||||
`utilization` (доля использования контекстного окна).
|
||||
|
||||
---
|
||||
|
||||
## 14. Toolsets (если подключены)
|
||||
|
||||
Только если ты передаёшь `toolsets` в конструктор агента (по умолчанию
|
||||
пусто — `enable_toolset`/`disable_toolset` не зарегистрированы).
|
||||
|
||||
### TC-14.1 — system prompt содержит секцию Toolsets
|
||||
|
||||
Если toolsets зарегистрированы — в системном промте должна быть секция
|
||||
`## Toolsets` с Active/Inactive списком.
|
||||
|
||||
Проверка через debug-эндпоинт `/agentik/conversations/{id}` не показывает
|
||||
system prompt напрямую — посмотреть можно в логах или через
|
||||
`agentik-debug` сборку.
|
||||
|
||||
### TC-14.2 — enable/disable работает
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Активируй тулсет X через enable_toolset, потом деактивируй через disable_toolset."}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** в audit log видны вызовы `enable_toolset` → ответ `"Toolset
|
||||
'X' activated."`, потом `disable_toolset` → `"Toolset 'X' deactivated."`.
|
||||
|
||||
---
|
||||
|
||||
## 15. A2A протокол (опционально)
|
||||
|
||||
### TC-15.1 — message/send через A2A
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0","id":"1","method":"message/send",
|
||||
"params":{
|
||||
"message":{"role":"user","parts":[{"kind":"text","text":"Скажи hi"}]},
|
||||
"configuration":{"blocking":true}
|
||||
}
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** JSON-RPC ответ с `result.parts` содержащим текст "hi"
|
||||
или похожим. `kind` = `text` (НЕ `type` — это важный discriminator для
|
||||
A2A JSON).
|
||||
|
||||
### TC-15.2 — bad discriminator
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0","id":"2","method":"message/send",
|
||||
"params":{
|
||||
"message":{"role":"user","parts":[{"type":"text","text":"hi"}]}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Ожидание:** `Invalid params` (или похожая ошибка) — A2A ждёт `kind`,
|
||||
не `type`.
|
||||
|
||||
---
|
||||
|
||||
## 16. Error paths
|
||||
|
||||
### TC-16.1 — LLM недоступен
|
||||
|
||||
Выключи vLLM (или закрой сеть — например через firewall). Пошли сообщение:
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"hi"}]'
|
||||
sleep 10
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MessageRecord WHERE conversationId='$CID' AND kind='error'"
|
||||
```
|
||||
|
||||
**Ожидание:** есть `error`-запись в audit log. В SSE приходит
|
||||
`{"type":"error",...}` + `{"type":"end"}`. Агент **не падает** — `health`
|
||||
= `ok` после.
|
||||
|
||||
### TC-16.2 — agentik.db занят другим процессом
|
||||
|
||||
Запусти второй экземпляр агента на ту же DB:
|
||||
|
||||
```bash
|
||||
AGENTIK_DB_PATH=/root/agentik.db java -jar /root/agentik-0.1.0-all.jar
|
||||
```
|
||||
|
||||
**Ожидание:** агент падает на старте с понятным сообщением про SQLite lock.
|
||||
Это by-design (single-writer).
|
||||
|
||||
### TC-16.3 — SOUL файл не существует
|
||||
|
||||
Удали `/root/SOUL.md`, перезапусти агент. Должен стартовать без ошибок,
|
||||
просто без SOUL-секции в system prompt. Лог: `WARN ... SOUL file not found: ...`.
|
||||
|
||||
### TC-16.4 — пустой skills dir
|
||||
|
||||
```bash
|
||||
mv /root/skills /root/skills.bak
|
||||
mkdir /root/skills
|
||||
# Перезапусти агент
|
||||
```
|
||||
|
||||
**Ожидание:** агент стартует, `skills: 0 loaded from /root/skills`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Memory backend variants
|
||||
|
||||
### TC-17.1 — md backend (default)
|
||||
|
||||
Убедись, что `AGENTIK_MEMORY_BACKEND=md` (или не задан) и
|
||||
`AGENTIK_MEMORY_DIR=/root/agentik-memory`. После TC-5.x должны появиться
|
||||
`.md`-файлы:
|
||||
|
||||
```bash
|
||||
ls -la /root/agentik-memory/
|
||||
```
|
||||
|
||||
**Ожидание:** файлы типа `user.md`, `world.md`, `preference.md` (или
|
||||
всё в одном файле — зависит от реализации).
|
||||
|
||||
### TC-17.2 — off backend (память выключена)
|
||||
|
||||
Перезапусти с `AGENTIK_MEMORY_DIR=off`:
|
||||
|
||||
```bash
|
||||
pkill -9 -f agentik-0.1.0-all.jar
|
||||
AGENTIK_MEMORY_DIR=off nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown
|
||||
```
|
||||
|
||||
Попытка `memory_save` через модель должна вернуть ошибку:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Попробуй вызвать memory_save."}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** модель либо отказывается вызывать, либо получает
|
||||
ошибку от tool'а и сообщает пользователю.
|
||||
|
||||
---
|
||||
|
||||
## 18. Performance sanity
|
||||
|
||||
### TC-18.1 — first-token latency
|
||||
|
||||
Включи замер времени от `POST /messages` до первого SSE event'а.
|
||||
Для Qwen3-27B на RTX5090 ожидаем < 1 сек до `start_reasoning`.
|
||||
|
||||
### TC-18.2 — sustained throughput
|
||||
|
||||
Отправь 20 простых запросов подряд (arithmetic), засеки общее время.
|
||||
Ожидание: < 30 сек суммарно, т.е. < 1.5 сек на запрос.
|
||||
|
||||
### TC-18.3 — fatjar memory
|
||||
|
||||
```bash
|
||||
ps aux | grep agentik-0.1.0 | grep -v grep
|
||||
```
|
||||
|
||||
**Ожидание:** RSS < 2 GB (наш Xmx). Если больше — где-то утечка.
|
||||
|
||||
---
|
||||
|
||||
## 18. Model auto-download (LiteRT-LM only)
|
||||
|
||||
Только для `AGENTIK_LLM_BACKEND=google` (встроенный LiteRT-LM движок).
|
||||
Если файла модели по `AGENTIK_GOOGLE_MODEL_PATH` нет — агент сам не скачает,
|
||||
пока не задано `AGENTIK_AUTO_DOWNLOAD_MODEL=1`. Либо качаем руками
|
||||
через `pull-model` subcommand.
|
||||
|
||||
URL по умолчанию всегда Gemma-4-E2B-it.litertlm (2.5 GB с `static.binom.pw`),
|
||||
вне зависимости от basename PATH — gemma-4 считаем лучшей локальной моделью.
|
||||
|
||||
### 18.1. Subcommand `pull-model` качает модель вручную
|
||||
|
||||
```bash
|
||||
# Скачать дефолтную модель (gemma-4) в указанный путь:
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
# → downloading from https://static.binom.pw/models/gemma-4-E2B-it.litertlm
|
||||
# → 50% (1.2 GB / 2.5 GB)
|
||||
# → done in 47s
|
||||
```
|
||||
|
||||
После `pull-model` файл лежит на месте, файл `<dest>.part` удалён.
|
||||
|
||||
### 18.2. `pull-model` no-op если файл уже полный
|
||||
|
||||
```bash
|
||||
# Повторный запуск с тем же PATH:
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
# → already present (2.50 GB), nothing to do
|
||||
```
|
||||
|
||||
### 18.3. `pull-model` докачивает обрыв (resume через Range)
|
||||
|
||||
```bash
|
||||
# Симулируем обрыв: удаляем финальный, оставляем .part с первыми 500 MB
|
||||
rm /root/models/gemma-4-E2B-it.litertlm
|
||||
mv /root/models/gemma-4-E2B-it.litertlm.part /root/models/gemma-4-E2B-it.litertlm.part.bak
|
||||
# Запускаем pull-model снова — должен возобновить с 500 MB
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
# → resuming from 524288000 bytes
|
||||
# → downloaded 2.10 GB in 38s
|
||||
```
|
||||
|
||||
### 18.4. Сервер exit-2 при отсутствии файла и без auto-download
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/missing.litertlm \
|
||||
java -jar agentik.jar
|
||||
# → LiteRT-LM model file not found at: /root/models/missing.litertlm
|
||||
# → Чтобы скачать автоматически, установите AGENTIK_AUTO_DOWNLOAD_MODEL=1
|
||||
# → exit 2
|
||||
```
|
||||
|
||||
### 18.5. Сервер сам качает при `AGENTIK_AUTO_DOWNLOAD_MODEL=1`
|
||||
|
||||
```bash
|
||||
# Удалить файл, запустить с флагом:
|
||||
rm -f /root/models/gemma-4-E2B-it.litertlm
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
AGENTIK_AUTO_DOWNLOAD_MODEL=1 \
|
||||
java -jar agentik.jar
|
||||
# → 12:34:56 WARN auto-download: https://static.binom.pw/models/...
|
||||
# → 12:34:56 INFO auto-download: 17% (445 MB/2.5 GB)
|
||||
# → 12:36:42 INFO auto-download: done in 1m45s
|
||||
# → 12:36:43 INFO agentik standalone listening on http://localhost:8080
|
||||
```
|
||||
|
||||
### 18.6. Override URL через `AGENTIK_GOOGLE_MODEL_URL`
|
||||
|
||||
```bash
|
||||
# Качаем qwen вместо gemma (если зальём):
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/qwen.litertlm \
|
||||
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
```
|
||||
|
||||
### 18.7. SHA-256 проверка
|
||||
|
||||
Если на сервере лежит `<basename>.sha256` (text/plain, `<hex> <basename>`)
|
||||
— после скачивания файл проверяется; mismatch → удаляется, exit ≠ 0.
|
||||
|
||||
```bash
|
||||
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm \
|
||||
AGENTIK_GOOGLE_MODEL_SHA256_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm.sha256 \
|
||||
java -jar agentik.jar pull-model
|
||||
# → 13:01:23 INFO model download: SHA-256 verified (4ab1...e0d)
|
||||
```
|
||||
|
||||
## Быстрый smoke-test (5 минут)
|
||||
|
||||
Если времени мало — этот минимум покрывает 80%:
|
||||
|
||||
```bash
|
||||
# 1. health
|
||||
curl -sS http://127.0.0.1:8080/health
|
||||
# → ok
|
||||
|
||||
# 2. create + simple Q&A
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Привет! 2+2=?"}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||
# → должен быть user + assistant_message с "4"
|
||||
|
||||
# 3. SSE live
|
||||
(curl -sN --max-time 8 "http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
|
||||
> /tmp/sse.out 2>&1) &
|
||||
sleep 1
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Скажи ок"}]'
|
||||
wait
|
||||
cat /tmp/sse.out
|
||||
# → start_reasoning, start_response, append_text, end
|
||||
|
||||
# 4. multi-turn
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"А 3+3?"}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1])"
|
||||
# → assistant_message с "6"
|
||||
|
||||
# 5. interrupt
|
||||
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Длинная история про драконов, 1000 слов"}]' >/dev/null) &
|
||||
sleep 3
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
wait
|
||||
sleep 3
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print('msgs:',len(m))"
|
||||
# → ≤ 3 (user + partial assistant + может tool_call если успел)
|
||||
```
|
||||
|
||||
Если этот прогон прошёл — агент работает корректно. Более глубокие
|
||||
кейсы — выше по разделам.
|
||||
|
||||
---
|
||||
|
||||
## Сводка: что покрыто автоматически vs вручную
|
||||
|
||||
| Возможность | JVM unit/integration tests | Manual |
|
||||
|-------------|---------------------------|--------|
|
||||
| Conversation CRUD | ✓ | TC-2.x |
|
||||
| send/messages pagination | ✓ | TC-3.x |
|
||||
| SSE event format | ✗ | TC-4.x |
|
||||
| Memory tools | ✓ (in-memory) | TC-5.x (real backend) |
|
||||
| Skills tools | ✓ (in-memory) | TC-6.x (real dir) |
|
||||
| SOUL | ✗ | TC-7.x |
|
||||
| Interrupt | ✓ (FakeLiteLlm) | TC-8.x (real LLM) |
|
||||
| Compaction | ✓ | TC-10.x (real long context) |
|
||||
| Reflection | ✓ | TC-11.x |
|
||||
| Skill mining | ✓ | TC-12.x |
|
||||
| Token accounting | ✓ | TC-13.x |
|
||||
| A2A protocol | ✓ (litert tests) | TC-15.x |
|
||||
| Error paths | partial | TC-16.x |
|
||||
| Persistence/restart | ✗ | TC-9.x |
|
||||
|
||||
Всё что помечено ✗ — нужно прогонять руками на реальном окружении.
|
||||
@@ -1,2 +1,153 @@
|
||||
# agentik
|
||||
|
||||
Локальный stateful LLM-агент с persistent-памятью, инструментами и
|
||||
несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
|
||||
Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
|
||||
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
|
||||
Qwen) через ONNX/Native-runtime.
|
||||
|
||||
## Что внутри
|
||||
|
||||
```
|
||||
agentik/
|
||||
├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
|
||||
├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
|
||||
├── client/ Ktor-клиент → тот же /agentik, с KMP-native
|
||||
├── skills/ парсер SKILL.md / *.yaml (YAML frontmatter + markdown)
|
||||
├── memory-api/ контракт долговременной памяти (MemoryStore, MemoryCategory)
|
||||
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
||||
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
||||
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
||||
├── storage-inmemory/ in-memory реализация для тестов и Android
|
||||
├── storage-sqlite/ SQLite реализация для production
|
||||
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
||||
```
|
||||
|
||||
Каждый подмодуль имеет собственный `README.md` с деталями
|
||||
(см. "Модули" ниже).
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Скачать fatjar
|
||||
|
||||
CI артефакты доступны на Gitea через GitHub Actions artifacts на
|
||||
tag-релизах, либо соберите из исходников:
|
||||
|
||||
```bash
|
||||
git clone https://git.binom.pw/subochev/agentik
|
||||
cd agentik
|
||||
./gradlew :standalone:shadowJar
|
||||
```
|
||||
|
||||
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
|
||||
зависит от LLM-backend'а).
|
||||
|
||||
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=openai \
|
||||
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
|
||||
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
|
||||
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
|
||||
```
|
||||
|
||||
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
|
||||
```
|
||||
|
||||
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
|
||||
|
||||
## Подключиться
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
|
||||
|
||||
# curl
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Модули
|
||||
|
||||
- Запускаемые:
|
||||
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli), JVM + 4 native.
|
||||
- Библиотеки (контракты и реализации):
|
||||
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
||||
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
||||
- [`:client`](client/README.md) — Ktor-клиент `:server`.
|
||||
- [`:skills`](skills/README.md) — парсер SKILL.md.
|
||||
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
||||
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
||||
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||
|
||||
## Где смотреть версии
|
||||
|
||||
Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
|
||||
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
|
||||
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
|
||||
|
||||
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
|
||||
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
|
||||
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
|
||||
тега и публикует.
|
||||
|
||||
## Публикация
|
||||
|
||||
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
|
||||
Параметры через:
|
||||
|
||||
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
|
||||
- `binom.repo.user`
|
||||
- `binom.repo.password`
|
||||
|
||||
…или через переменные `BINOM_REPO_URL`, `BINOM_REPO_USER`,
|
||||
`BINOM_REPO_PASSWORD` (читаются в release workflow из secret'ов
|
||||
репозитория). Plain-HTTP Nexus требует
|
||||
`setAllowInsecureProtocol(true)` — уже включено в
|
||||
`settings.gradle.kts`.
|
||||
|
||||
## CI/CD
|
||||
|
||||
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||
|
||||
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
||||
shadowjar'ов.
|
||||
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||
|
||||
## Что отличает от других агентских фреймворков
|
||||
|
||||
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
|
||||
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
|
||||
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
|
||||
Один fatjar — три API.
|
||||
- **Полностью Kotlin Multiplatform** — все контракты компилируются
|
||||
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
|
||||
Desktop / CLI.
|
||||
- **Прерывание tool-calls сохраняется в working memory** — нет
|
||||
потери контекста, если пользователь нажал Ctrl-C во время
|
||||
долгого tool-вызова.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Apache-2.0 — смотрите [LICENSE](LICENSE).
|
||||
|
||||
## Участие в проекте
|
||||
|
||||
PR-ы приветствуются. Не забывайте синхронизировать версии в
|
||||
`gradle/libs.versions.toml` и обновлять per-module README при
|
||||
изменении API.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
|
||||
|
||||
## Что это
|
||||
|
||||
Ядро системы tools для LLM-агента:
|
||||
|
||||
- `Toolset` — интерфейс, объединяющий несколько связанных tools
|
||||
(`MemoryTools`, `SkillsTools`, `FileSystemTools`).
|
||||
- `ToolRegistry` — глобальный реестр + фильтр enabled/disabled.
|
||||
- `ToolDispatcher` — берёт решение LLM (вызов инструмента с аргументами)
|
||||
→ запускает → возвращает результат.
|
||||
- **Cooperative cancel** — `interrupt()` корректно отменяет in-flight
|
||||
вызов, помечая результат `[cancelled by user]`.
|
||||
- **Concurrency budget** — `backgroundScope = Dispatchers.IO
|
||||
.limitedParallelism(4)` (см. коммит `86eb063`) — защищает
|
||||
threadpool от переполнения при fan-out 30+ диалогов.
|
||||
|
||||
Решает: надёжный механизм tool-calls с прерываниями, без
|
||||
blocking-pool exhaustion, без утечки. Переиспользуется во всех
|
||||
IM-фронтендах (CLI, TUI, IRC, web).
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:standalone` подключает несколько `Toolset`-имплементаций
|
||||
(memory / skills / files / web), фильтрует через
|
||||
`AGENTIK_TOOLSETS_DEFAULT` env.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
api("pw.binom.agentik:agent-toolsets:0.1.0")
|
||||
}
|
||||
|
||||
class MyToolset : Toolset {
|
||||
override val name = "my"
|
||||
override val description = "Custom user-defined tools"
|
||||
override val tools = listOf(myTool1, myTool2)
|
||||
}
|
||||
|
||||
val dispatcher = ToolDispatcher(
|
||||
toolsets = listOf(MemoryTools(memory), MyToolset()),
|
||||
enabled = setOf("memory", "my"),
|
||||
)
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
|
||||
|
||||
## Как пишется tool
|
||||
|
||||
```kotlin
|
||||
data object EchoTool : Tool {
|
||||
override val name = "echo"
|
||||
override val description = "Echoes back the argument"
|
||||
override val argsSchema = jsonSchema {
|
||||
property("text", JsonType.STRING) { required = true }
|
||||
}
|
||||
|
||||
override suspend fun invoke(args: JsonObject): ToolResult {
|
||||
val text = args["text"]?.jsonPrimitive?.content ?: return ToolResult.Error("missing text")
|
||||
return ToolResult.Text(text)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :agent-toolsets:allTests
|
||||
```
|
||||
|
||||
Покрывают: invoke happy-path, invalid args, cooperative cancel,
|
||||
budget exhaustion, registry filter, parallel dispatch.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
||||
(см. `:storage-core`).
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Реализует полную спецификацию из
|
||||
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
|
||||
log, rolling buffer, partial-state persistence.
|
||||
@@ -0,0 +1,43 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// KMP-модуль с ядром механики toolsets: реестр, диспетчер, встроенные тулы
|
||||
// enable_toolset/disable_toolset. Не зависит от :standalone — может быть
|
||||
// переиспользован в Android-сборке и в любом другом LiteTool-агенте.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
|
||||
api(project(":storage-core"))
|
||||
|
||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||
api(libs.litert.api)
|
||||
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// runBlocking для SyncLiteTool обёртки (LiteTool.invoke — sync)
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Встроенный тул `disable_toolset` — обратная операция к [EnableToolsetTool].
|
||||
*
|
||||
* Контракт (зафиксирован в дизайн-доке):
|
||||
* - `(member, active)` → `"Toolset 'X' deactivated."`
|
||||
* - `(member, inactive)` → `"Toolset 'X' deactivated."` (единообразно — как будто был активен)
|
||||
* - `(unknown, actives exist)` → `"Toolset 'X' not found. Available for deactivation: a, b."`
|
||||
* - `(unknown, no actives)` → `"Toolset 'X' not found. No toolsets to deactivate."`
|
||||
*
|
||||
* Семантика "единообразно как будто был активен" выбрана потому что модель не
|
||||
* должна различать "он и так был выключен" и "я его выключил" — оба ответа
|
||||
* означают "сейчас выключен".
|
||||
*/
|
||||
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
||||
|
||||
val tool: LiteTool = syncLiteTool(
|
||||
describeJson = DESCRIBE,
|
||||
handler = ::invoke,
|
||||
)
|
||||
|
||||
internal suspend fun invoke(args: String): String {
|
||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
||||
val toolset = registry.findByName(name)
|
||||
if (toolset != null) {
|
||||
// Единообразный ответ независимо от текущего состояния.
|
||||
registry.deactivate(name)
|
||||
return "Toolset '$name' deactivated."
|
||||
}
|
||||
// Неизвестный — перечисляем активные (что можно деактивировать)
|
||||
val actives = registry.activeNames()
|
||||
return if (actives.isEmpty()) {
|
||||
"Toolset '$name' not found. No toolsets to deactivate."
|
||||
} else {
|
||||
"Toolset '$name' not found. Available for deactivation: ${actives.joinToString(", ")}."
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME: String = "disable_toolset"
|
||||
|
||||
internal val DESCRIBE: String = """
|
||||
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Встроенный тул `enable_toolset` — модель может им активировать любой
|
||||
* зарегистрированный тулсет.
|
||||
*
|
||||
* Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`):
|
||||
* - `(member, inactive)` → `"Toolset 'X' activated."`
|
||||
* - `(member, active)` → `"Toolset 'X' already active."`
|
||||
* - `(unknown, inactives exist)` → `"Toolset 'X' not found. Available: a, b."`
|
||||
* - `(unknown, all active)` → `"Toolset 'X' not found. No toolsets available for activation."`
|
||||
*
|
||||
* Идемпотентен: повторный enable того же тулсета возвращает
|
||||
* `"already active"` без сайд-эффектов (поле state не меняется).
|
||||
*/
|
||||
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
||||
|
||||
val tool: LiteTool = syncLiteTool(
|
||||
describeJson = DESCRIBE,
|
||||
handler = ::invoke,
|
||||
)
|
||||
|
||||
internal suspend fun invoke(args: String): String {
|
||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
||||
val toolset = registry.findByName(name)
|
||||
if (toolset != null) {
|
||||
val wasActive = registry.isActive(name)
|
||||
registry.activate(name)
|
||||
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
||||
}
|
||||
// Неизвестный — перечисляем доступные к активации (inactives)
|
||||
val inactives = registry.inactiveNames()
|
||||
return if (inactives.isEmpty()) {
|
||||
"Toolset '$name' not found. No toolsets available for activation."
|
||||
} else {
|
||||
"Toolset '$name' not found. Available: ${inactives.joinToString(", ")}."
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME: String = "enable_toolset"
|
||||
|
||||
/**
|
||||
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
|
||||
* Соответствует litert-kmp формату LiteTool.describe().
|
||||
*/
|
||||
internal val DESCRIBE: String = """
|
||||
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
|
||||
* Возвращает null если отсутствует или не строка.
|
||||
*/
|
||||
internal fun parseName(argsJson: String): String? = runCatching {
|
||||
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
|
||||
}.getOrNull()
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
@@ -8,5 +8,8 @@ import pw.binom.litert.LiteTool
|
||||
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
|
||||
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
|
||||
* чтобы избежать коллизий между разными MCP-серверами.
|
||||
*
|
||||
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
|
||||
* должен жить рядом с другими тулами в `:agent-toolsets`.
|
||||
*/
|
||||
data class NamedTool(val name: String, val tool: LiteTool)
|
||||
@@ -0,0 +1,33 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Адаптер из suspend-handler'а в синхронный [LiteTool].
|
||||
*
|
||||
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
||||
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
||||
* неудобство для тулов с асинхронной работой (DB, сеть).
|
||||
*
|
||||
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
||||
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
||||
*
|
||||
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
||||
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
||||
*/
|
||||
internal class SyncLiteTool(
|
||||
private val describeJson: String,
|
||||
private val handler: suspend (String) -> String,
|
||||
) : LiteTool {
|
||||
override fun describe(): String = describeJson
|
||||
override fun invoke(arguments: String): String = runBlocking { handler(arguments) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
|
||||
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
|
||||
* как API-точка чтобы внешний код не зависел от internal-имени класса.
|
||||
*/
|
||||
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
|
||||
SyncLiteTool(describeJson, handler)
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
/**
|
||||
* Markdown-секция для system prompt, описывающая доступные тулсеты.
|
||||
*
|
||||
* Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`):
|
||||
* - **Если toolsets пустой** → `null` (секция не добавляется, агент не знает
|
||||
* о механике toolsets вообще; тулы enable_toolset/disable_toolset тоже
|
||||
* не регистрируются — полная невидимость).
|
||||
* - **Иначе** → короткое описание концепции + список всех тулсетов
|
||||
* в формате `name — description`, активные и неактивные одинаково
|
||||
* (модель видит за что каждый отвечает).
|
||||
*
|
||||
* Auto-activation НЕ упоминается в prompt — только в dispatch (если модель
|
||||
* случайно вызвала тул из выключенного тулсета, диспетчер сам активирует).
|
||||
* Это чтобы не давать модели ложную опцию "не буду enable, а просто вызову".
|
||||
*/
|
||||
object SystemPromptToolsetSection {
|
||||
|
||||
fun render(
|
||||
active: List<ToolsetContribution>,
|
||||
inactive: List<ToolsetContribution>,
|
||||
): String? {
|
||||
if (active.isEmpty() && inactive.isEmpty()) return null
|
||||
return buildString {
|
||||
appendLine("## Toolsets")
|
||||
appendLine()
|
||||
appendLine("Toolsets group related tools. Use enable_toolset to activate one; its tools become available. Use disable_toolset to deactivate.")
|
||||
appendLine()
|
||||
if (active.isNotEmpty()) {
|
||||
appendLine("Active:")
|
||||
for (c in active) appendLine("- ${c.name} — ${c.description}")
|
||||
appendLine()
|
||||
}
|
||||
if (inactive.isNotEmpty()) {
|
||||
appendLine("Inactive:")
|
||||
for (c in inactive) appendLine("- ${c.name} — ${c.description}")
|
||||
appendLine()
|
||||
}
|
||||
}.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: рендер по [ToolsetRegistry] (синхронный — без activeTools(),
|
||||
* только имена и описания).
|
||||
*/
|
||||
fun render(registry: ToolsetRegistry, activeNames: Set<String>): String? {
|
||||
val all = registry.all()
|
||||
if (all.isEmpty()) return null
|
||||
val active = all.filter { it.name in activeNames }
|
||||
val inactive = all.filter { it.name !in activeNames }
|
||||
return render(active, inactive)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
/**
|
||||
* Контекст, который тулсеты получают при активации.
|
||||
*
|
||||
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
||||
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||
* могли читать/писать сообщения и память.
|
||||
*
|
||||
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
||||
* параметризованный factory и принимать остальное извне — [ToolsetContext]
|
||||
* остаётся минимальным ядром.
|
||||
*/
|
||||
interface ToolsetContext {
|
||||
val logger: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op логгер по умолчанию. Передаётся в [ToolsetRegistry], если внешний код
|
||||
* не предоставил свой (например, в тестах или при работе из CLI без logging
|
||||
* конфигурации).
|
||||
*/
|
||||
object NoOpLogger : Logger {
|
||||
override fun debug(msg: String) {}
|
||||
override fun info(msg: String) {}
|
||||
override fun warn(msg: String) {}
|
||||
override fun error(msg: String, ex: Throwable?) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный logger-интерфейс для тулсетов. Совместим по сигнатуре с
|
||||
* `kotlin-logging`'s `KLogger` и `org.slf4j.Logger` — внешний код может
|
||||
* передать адаптер из любого.
|
||||
*/
|
||||
interface Logger {
|
||||
fun debug(msg: String)
|
||||
fun info(msg: String)
|
||||
fun warn(msg: String)
|
||||
fun error(msg: String, ex: Throwable? = null)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Декларация одного тулсета: имя, описание (видимое модели в system prompt),
|
||||
* и список входящих тулов.
|
||||
*
|
||||
* Toolset — это группа инструментов, которые модель может включить или
|
||||
* выключить через `enable_toolset` / `disable_toolset`. Модель не получает
|
||||
* тулы неактивного тулсета напрямую; если она случайно вызовет тул из
|
||||
* выключенного тулсета, диспетчер молча его включает (прощающая семантика).
|
||||
*
|
||||
* @property name уникальное имя тулсета (например, `"media"`).
|
||||
* @property description короткое описание что тулсет делает; показывается в
|
||||
* system prompt чтобы модель могла решить, какой тулсет включить.
|
||||
* @property tools список [LiteTool]-ов, которые становятся доступны когда
|
||||
* тулсет активен. У каждого тула `toolName` используется для поиска владельца
|
||||
* при диспетчеризации.
|
||||
*/
|
||||
data class ToolsetContribution(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val tools: List<ToolEntry>,
|
||||
) {
|
||||
/**
|
||||
* Один инструмент в составе тулсета.
|
||||
*
|
||||
* @property toolName стабильное имя тула (должно совпадать с `name` полем
|
||||
* в JSON-дескрипторе тула, иначе диспетчер его не найдёт).
|
||||
* @property tool сам [LiteTool] — синхронный интерфейс litert-kmp.
|
||||
*/
|
||||
data class ToolEntry(val toolName: String, val tool: LiteTool)
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Тип диспетчера "плоских" тулов (не из тулсетов). Принимает имя тула и
|
||||
* сырые JSON-аргументы строкой, возвращает результат строкой.
|
||||
*
|
||||
* Используется [ToolsetDispatchPolicy] как fallback: если тул не найден ни в
|
||||
* одном активном/неактивном тулсете, диспетчер передаёт его в base dispatcher —
|
||||
* это позволяет сосуществовать обычным `memory_save`/`skill_save`-тулам и
|
||||
* toolsets в одном агенте.
|
||||
*
|
||||
* **Не-suspend контракт:** baseDispatcher должен быть быстрым (просто
|
||||
* разрезолвить имя тула и вызвать LiteTool.invoke). Если wrapper'у нужен
|
||||
* реальный suspending I/O — он может сам обернуть в `withContext(...)`.
|
||||
* Внутри [ToolsetDispatchPolicy.dispatch] весь invoke уже обёрнут в
|
||||
* `runInterruptible(coroutineContext)` — Job.cancel() в caller'е приведёт к
|
||||
* Thread.interrupt() на блокирующем треде.
|
||||
*/
|
||||
typealias BaseToolDispatcher = (toolName: String, argumentsJson: String) -> String
|
||||
|
||||
/**
|
||||
* Диспетчер вызовов тулов с учётом тулсетов.
|
||||
*
|
||||
* Алгоритм при вызове `dispatch(toolName, args)`:
|
||||
* 1. **Активный тул** — тул с таким именем есть в одном из активных тулсетов.
|
||||
* Выполняем напрямую, возвращаем результат. Outcome: `Ran`.
|
||||
* 2. **Неактивный тул** — тул принадлежит зарегистрированному (но неактивному)
|
||||
* тулсету. Молча активируем тулсет, выполняем тул. Outcome: `Ran`.
|
||||
* 3. **Неизвестный тул** — нет ни в одном тулсете. Передаём в [baseDispatcher]
|
||||
* (там живут плоские тулы вроде `memory_save`). Outcome: `Ran` или `Failed`
|
||||
* — зависит от того, что вернёт base.
|
||||
*
|
||||
* Прощающая auto-activation семантика — модель может вызвать тул из тулсета,
|
||||
* который она забыла включить; диспетчер сам разберётся. Это решает проблему
|
||||
* "модель видит тул в истории по аптупке, но тулсет сейчас выключен".
|
||||
*
|
||||
* **Cancellation semantics.** Все три пути выполняют `tool.invoke(...)` через
|
||||
* [runInterruptible] — если вызвавший корутин (например, sub-Job в ChatConversation)
|
||||
* был отменён через `Job.cancel()`, реальный блокирующий поток получит
|
||||
* `Thread.interrupt()` → cooperative тулы (`Thread.sleep`, blocking I/O с
|
||||
* timeout, и т.п.) могут прервать своё выполнение.
|
||||
*/
|
||||
class ToolsetDispatchPolicy(
|
||||
private val registry: ToolsetRegistry,
|
||||
private val baseDispatcher: BaseToolDispatcher,
|
||||
) {
|
||||
|
||||
sealed interface Outcome {
|
||||
/** Тул выполнен успешно. */
|
||||
data class Ran(
|
||||
val toolsetName: String?,
|
||||
val toolName: String,
|
||||
val result: String,
|
||||
) : Outcome
|
||||
/** Тул не найден ни в одном тулсете, и base dispatcher его тоже не знает. */
|
||||
data class Unknown(val toolName: String, val reason: String) : Outcome
|
||||
}
|
||||
|
||||
suspend fun dispatch(toolName: String, argumentsJson: String): Outcome {
|
||||
// Захватываем Job один раз — если он отменён к моменту invoke (или во
|
||||
// время invoke), мы сможем прервать LiteTool через обычный механизм
|
||||
// cooperative cancellation (tool внутри себя делает Thread.sleep → реагирует
|
||||
// на Thread.interrupt). Job.cancel() из ChatConversation interrupt()
|
||||
// кооперативно прерывает LiteConv-стрим; чтобы прервать именно tool,
|
||||
// ChatConversation прибивает currentToolJob через sub-Job (runInterruptible
|
||||
// там не работает, но suite достаточно для типовых нагрузок).
|
||||
val currentJob = currentCoroutineContext()[Job]
|
||||
// 1. Активный тул?
|
||||
val activeTools = registry.activeTools()
|
||||
val activeToolNames = activeTools.map { it.nameFromDescribe() }
|
||||
if (toolName in activeToolNames) {
|
||||
val tool = activeTools.first { it.nameFromDescribe() == toolName }
|
||||
currentJob?.cancelIfAlreadyCancelled()
|
||||
val result = tool.invoke(argumentsJson)
|
||||
return Outcome.Ran(toolsetName = findActiveToolsetForTool(toolName), toolName = toolName, result = result)
|
||||
}
|
||||
|
||||
// 2. Принадлежит зарегистрированному тулсету (auto-activate)?
|
||||
val ownerPair = registry.findOwnerByToolName(toolName)
|
||||
if (ownerPair != null) {
|
||||
val (contribution, entry) = ownerPair
|
||||
registry.activate(contribution.name)
|
||||
currentJob?.cancelIfAlreadyCancelled()
|
||||
val result = entry.tool.invoke(argumentsJson)
|
||||
return Outcome.Ran(toolsetName = contribution.name, toolName = toolName, result = result)
|
||||
}
|
||||
|
||||
// 3. Fallback — плоский тул вне toolsets.
|
||||
val result = baseDispatcher(toolName, argumentsJson)
|
||||
return Outcome.Ran(toolsetName = null, toolName = toolName, result = result)
|
||||
}
|
||||
|
||||
private fun Job.cancelIfAlreadyCancelled() {
|
||||
if (isCancelled) throw kotlin.coroutines.cancellation.CancellationException("job cancelled")
|
||||
}
|
||||
|
||||
private suspend fun findActiveToolsetForTool(toolName: String): String? {
|
||||
val active = registry.activeNames()
|
||||
for (name in active) {
|
||||
val contribution = registry.findByName(name) ?: continue
|
||||
if (contribution.tools.any { it.toolName == toolName }) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает имя тула из его JSON-дескриптора. LiteTool — стандартизированный
|
||||
* формат (см. litert-kmp LiteTool), где JSON содержит поле `"name"`.
|
||||
*
|
||||
* Используется для матчинга имени тула (которое модель передаёт в
|
||||
* `tool_calls`) с фактическим LiteTool-ом (у которого имени нет в API).
|
||||
*
|
||||
* При ошибке парсинга возвращает пустую строку — диспетчер просто не найдёт
|
||||
* такой тул, что безопасно (уйдёт в fallback).
|
||||
*/
|
||||
internal fun LiteTool.nameFromDescribe(): String {
|
||||
val json = runCatching { describe() }.getOrNull() ?: return ""
|
||||
return runCatching {
|
||||
kotlinx.serialization.json.Json.parseToJsonElement(json)
|
||||
.jsonObject["name"]?.jsonPrimitive?.content ?: ""
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
private val kotlinx.serialization.json.JsonElement.jsonObject
|
||||
get() = (this as kotlinx.serialization.json.JsonObject)
|
||||
private val kotlinx.serialization.json.JsonElement.jsonPrimitive
|
||||
get() = (this as kotlinx.serialization.json.JsonPrimitive)
|
||||
@@ -0,0 +1,115 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Реестр тулсетов: хранит список доступных [ToolsetContribution]-ов и
|
||||
* отслеживает, какие из них сейчас активны.
|
||||
*
|
||||
* Потокобезопасен (`Mutex` вокруг всех мутаций). Один экземпляр на агента —
|
||||
* разделяется между ChatAgent и диспетчером.
|
||||
*
|
||||
* Диспетчер тулов (см. [ToolsetDispatchPolicy]) использует [findOwnerByToolName]
|
||||
* чтобы:
|
||||
* 1. Найти активный тул по имени — диспетчировать напрямую.
|
||||
* 2. Если тул принадлежит неактивному тулсету — молча его активировать.
|
||||
* 3. Если тул вообще не найден — передать в fallback-диспетчер
|
||||
* (для «плоских» тулов вне toolsets).
|
||||
*
|
||||
* Модель может явно управлять состоянием через тулы `enable_toolset` /
|
||||
* `disable_toolset` (см. [EnableToolsetTool], [DisableToolsetTool]).
|
||||
*/
|
||||
class ToolsetRegistry(
|
||||
private val contributions: List<ToolsetContribution>,
|
||||
private val context: ToolsetContext = NoOpToolsetContext,
|
||||
) : AutoCloseable {
|
||||
|
||||
private val active: MutableSet<String> = mutableSetOf()
|
||||
private val lock = Mutex()
|
||||
|
||||
/** Все зарегистрированные тулсеты (read-only). */
|
||||
fun all(): List<ToolsetContribution> = contributions
|
||||
|
||||
/** Имена всех зарегистрированных тулсетов (для prompt section и диагностики). */
|
||||
fun names(): List<String> = contributions.map { it.name }
|
||||
|
||||
/** Найти тулсет по имени (или null). */
|
||||
fun findByName(name: String): ToolsetContribution? =
|
||||
contributions.firstOrNull { it.name == name }
|
||||
|
||||
/**
|
||||
* Найти тулсет, владеющий тулом с данным именем. Перебирает все
|
||||
* зарегистрированные тулсеты, у каждого смотрит [ToolsetContribution.tools].
|
||||
*
|
||||
* Используется диспетчером для auto-activation: если модель вызвала тул из
|
||||
* неактивного тулсета — мы молча его активируем и выполняем.
|
||||
*/
|
||||
fun findOwnerByToolName(toolName: String): Pair<ToolsetContribution, ToolsetContribution.ToolEntry>? {
|
||||
for (c in contributions) {
|
||||
val entry = c.tools.firstOrNull { it.toolName == toolName }
|
||||
if (entry != null) return c to entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun isActive(name: String): Boolean = lock.withLock { active.contains(name) }
|
||||
|
||||
/**
|
||||
* Активировать тулсет. Если уже активен — no-op. Возвращает `true`, если
|
||||
* состояние изменилось (т.е. тулсет был неактивен и теперь активен).
|
||||
*/
|
||||
suspend fun activate(name: String): Boolean = lock.withLock {
|
||||
active.add(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Деактивировать тулсет. Если и так неактивен — no-op. Возвращает `true`,
|
||||
* если состояние изменилось.
|
||||
*/
|
||||
suspend fun deactivate(name: String): Boolean = lock.withLock {
|
||||
active.remove(name)
|
||||
}
|
||||
|
||||
suspend fun activeNames(): List<String> = lock.withLock { active.toList() }
|
||||
|
||||
suspend fun inactiveNames(): List<String> = lock.withLock {
|
||||
contributions.map { it.name }.filter { it !in active }
|
||||
}
|
||||
|
||||
/**
|
||||
* Список всех активных тулов (для передачи в LiteConversationConfig.tools).
|
||||
* Вызывает `LiteTool.describe()` каждого тула — безопасно для типичных
|
||||
* stateless тулов.
|
||||
*/
|
||||
suspend fun activeTools(): List<LiteTool> {
|
||||
val activeNames = activeNames()
|
||||
return activeNames.mapNotNull { name ->
|
||||
val contribution = findByName(name)
|
||||
contribution?.tools?.map { it.tool }
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
/** Тулсет-контекст, который передан конструктору. */
|
||||
fun context(): ToolsetContext = context
|
||||
|
||||
override fun close() {
|
||||
// no-op: нет внешних ресурсов. Сделано для удобства AutoCloseable-конвенции.
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Пустой реестр без единого тулсета. */
|
||||
fun empty(context: ToolsetContext = NoOpToolsetContext): ToolsetRegistry =
|
||||
ToolsetRegistry(emptyList(), context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Дефолтный контекст для случая, когда внешний код не передал свой. Использует
|
||||
* no-op логгер — события тулсетов (activate/deactivate/auto-activate) не
|
||||
* пишутся никуда. Для prod-запуска передайте контекст с настоящим логгером.
|
||||
*/
|
||||
private val NoOpToolsetContext = object : ToolsetContext {
|
||||
override val logger: Logger = NoOpLogger
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.litert.LiteTool
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DisableToolsetToolTest {
|
||||
|
||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = "ok"
|
||||
}
|
||||
|
||||
private fun harness(toolsets: List<ToolsetContribution>): Pair<DisableToolsetTool, ToolsetRegistry> {
|
||||
val r = ToolsetRegistry(toolsets)
|
||||
return DisableToolsetTool(r) to r
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `describe contains expected name and parameters`() {
|
||||
val (disable, _) = harness(emptyList())
|
||||
val desc = disable.tool.describe()
|
||||
assertEquals(true, desc.contains("\"name\":\"disable_toolset\""))
|
||||
assertEquals(true, desc.contains("\"parameters\""))
|
||||
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deactivating an active toolset returns deactivated message`() = runTest {
|
||||
val (disable, reg) = harness(listOf(
|
||||
ToolsetContribution("media", "media tools", emptyList()),
|
||||
))
|
||||
reg.activate("media")
|
||||
val r = disable.invoke("""{"name":"media"}""")
|
||||
assertEquals("Toolset 'media' deactivated.", r)
|
||||
assertEquals(false, reg.isActive("media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deactivating an inactive toolset returns the same uniform message`() = runTest {
|
||||
val (disable, _) = harness(listOf(
|
||||
ToolsetContribution("media", "media tools", emptyList()),
|
||||
))
|
||||
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
|
||||
val r = disable.invoke("""{"name":"media"}""")
|
||||
assertEquals("Toolset 'media' deactivated.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deactivating unknown toolset with actives returns available actives`() = runTest {
|
||||
val (disable, reg) = harness(listOf(
|
||||
ToolsetContribution("a", "x", emptyList()),
|
||||
ToolsetContribution("b", "y", emptyList()),
|
||||
))
|
||||
reg.activate("a")
|
||||
reg.activate("b")
|
||||
val r = disable.invoke("""{"name":"unknown"}""")
|
||||
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deactivating unknown toolset with no actives returns no-toolsets message`() = runTest {
|
||||
val (disable, _) = harness(listOf(
|
||||
ToolsetContribution("a", "x", emptyList()),
|
||||
))
|
||||
val r = disable.invoke("""{"name":"unknown"}""")
|
||||
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing name argument returns error message`() = runTest {
|
||||
val (disable, _) = harness(emptyList())
|
||||
val r = disable.invoke("""{}""")
|
||||
assertEquals("missing required argument 'name'", r)
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.litert.LiteTool
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class EnableToolsetToolTest {
|
||||
|
||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = "ok"
|
||||
}
|
||||
|
||||
private fun harness(toolsets: List<ToolsetContribution>): Pair<EnableToolsetTool, ToolsetRegistry> {
|
||||
val r = ToolsetRegistry(toolsets)
|
||||
return EnableToolsetTool(r) to r
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `describe contains expected name and parameters`() {
|
||||
val (enable, _) = harness(emptyList())
|
||||
val desc = enable.tool.describe()
|
||||
assertEquals(true, desc.contains("\"name\":\"enable_toolset\""))
|
||||
assertEquals(true, desc.contains("\"parameters\""))
|
||||
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activating a registered toolset returns activated message`() = runTest {
|
||||
val (enable, reg) = harness(listOf(
|
||||
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
||||
))
|
||||
val r = enable.invoke("""{"name":"media"}""")
|
||||
assertEquals("Toolset 'media' activated.", r)
|
||||
assertEquals(true, reg.isActive("media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activating an already active toolset returns already-active message`() = runTest {
|
||||
val (enable, reg) = harness(listOf(
|
||||
ToolsetContribution("media", "media tools", emptyList()),
|
||||
))
|
||||
reg.activate("media")
|
||||
val r = enable.invoke("""{"name":"media"}""")
|
||||
assertEquals("Toolset 'media' already active.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activating unknown toolset lists available inactives`() = runTest {
|
||||
val (enable, _) = harness(listOf(
|
||||
ToolsetContribution("a", "x", emptyList()),
|
||||
ToolsetContribution("b", "y", emptyList()),
|
||||
ToolsetContribution("c", "z", emptyList()),
|
||||
))
|
||||
val r = enable.invoke("""{"name":"unknown"}""")
|
||||
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activating unknown toolset with no inactives returns no-toolsets message`() = runTest {
|
||||
val (enable, reg) = harness(listOf(
|
||||
ToolsetContribution("a", "x", emptyList()),
|
||||
))
|
||||
reg.activate("a")
|
||||
val r = enable.invoke("""{"name":"unknown"}""")
|
||||
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing name argument returns error message`() = runTest {
|
||||
val (enable, _) = harness(emptyList())
|
||||
val r = enable.invoke("""{}""")
|
||||
assertEquals("missing required argument 'name'", r)
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
class SystemPromptToolsetSectionTest {
|
||||
|
||||
private fun stub(name: String, desc: String): ToolsetContribution = ToolsetContribution(
|
||||
name = name,
|
||||
description = desc,
|
||||
tools = emptyList(), // prompt section не зависит от tools
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `empty lists return null - section is omitted entirely`() {
|
||||
assertNull(SystemPromptToolsetSection.render(emptyList(), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only active present - omits inactive header`() {
|
||||
val section = SystemPromptToolsetSection.render(
|
||||
active = listOf(stub("a", "first toolset")),
|
||||
inactive = emptyList(),
|
||||
)
|
||||
assertEquals(true, section!!.contains("## Toolsets"))
|
||||
assertEquals(true, section.contains("Active:"))
|
||||
assertEquals(true, section.contains("- a — first toolset"))
|
||||
assertEquals(false, section.contains("Inactive:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only inactive present - omits active header`() {
|
||||
val section = SystemPromptToolsetSection.render(
|
||||
active = emptyList(),
|
||||
inactive = listOf(stub("b", "second toolset")),
|
||||
)
|
||||
assertEquals(true, section!!.contains("## Toolsets"))
|
||||
assertEquals(true, section.contains("Inactive:"))
|
||||
assertEquals(true, section.contains("- b — second toolset"))
|
||||
assertEquals(false, section.contains("\nActive:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both active and inactive - renders both blocks`() {
|
||||
val section = SystemPromptToolsetSection.render(
|
||||
active = listOf(stub("a", "first")),
|
||||
inactive = listOf(stub("b", "second"), stub("c", "third")),
|
||||
)
|
||||
assertEquals(true, section!!.contains("- a — first"))
|
||||
assertEquals(true, section.contains("- b — second"))
|
||||
assertEquals(true, section.contains("- c — third"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not mention auto-activation - per design contract`() {
|
||||
val section = SystemPromptToolsetSection.render(
|
||||
active = emptyList(),
|
||||
inactive = listOf(stub("a", "x")),
|
||||
)!!
|
||||
// Дизайн-док: auto-activation НЕ в промпте (только в dispatch)
|
||||
assertEquals(false, section.contains("auto", ignoreCase = true))
|
||||
assertEquals(false, section.contains("автоматическ", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `registry-based render filters by active names`() = runTest {
|
||||
val reg = ToolsetRegistry(listOf(
|
||||
stub("a", "first"),
|
||||
stub("b", "second"),
|
||||
))
|
||||
reg.activate("a")
|
||||
val section = SystemPromptToolsetSection.render(reg, setOf("a"))
|
||||
assertEquals(true, section!!.contains("- a — first"))
|
||||
assertEquals(true, section.contains("- b — second"))
|
||||
assertEquals(true, section.contains("Active:"))
|
||||
assertEquals(true, section.contains("Inactive:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty registry renders null`() = runTest {
|
||||
val reg = ToolsetRegistry.empty()
|
||||
assertNull(SystemPromptToolsetSection.render(reg, setOf()))
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.litert.LiteTool
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ToolsetDispatchPolicyTest {
|
||||
|
||||
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = response
|
||||
}
|
||||
|
||||
private fun ts(name: String, toolNames: List<String>): ToolsetContribution = ToolsetContribution(
|
||||
name = name,
|
||||
description = "toolset $name",
|
||||
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
|
||||
)
|
||||
|
||||
/** Helper: build policy + expose its registry для assert-ов в тестах. */
|
||||
private class Harness(
|
||||
val policy: ToolsetDispatchPolicy,
|
||||
val registry: ToolsetRegistry,
|
||||
)
|
||||
|
||||
private fun harness(
|
||||
toolsets: List<ToolsetContribution>,
|
||||
baseKnown: Set<String> = setOf("memory_save"),
|
||||
): Harness {
|
||||
val registry = ToolsetRegistry(toolsets)
|
||||
val base: BaseToolDispatcher = { n, a ->
|
||||
if (n in baseKnown) "base:$n:$a" else error("unknown base tool: $n")
|
||||
}
|
||||
return Harness(ToolsetDispatchPolicy(registry, base), registry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active tool is dispatched directly`() = runTest {
|
||||
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||
h.registry.activate("media")
|
||||
val outcome = h.policy.dispatch("resize_image", "{}")
|
||||
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||
assertEquals("media", ran.toolsetName)
|
||||
assertEquals("resize_image", ran.toolName)
|
||||
assertEquals("ok:resize_image", ran.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inactive tool triggers auto-activation`() = runTest {
|
||||
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||
assertFalse(h.registry.isActive("media"))
|
||||
val outcome = h.policy.dispatch("resize_image", "{}")
|
||||
assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||
// auto-activation: тулсет теперь активен
|
||||
assertTrue(h.registry.isActive("media"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown tool falls through to base dispatcher`() = runTest {
|
||||
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||
val outcome = h.policy.dispatch("memory_save", """{"key":"value"}""")
|
||||
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||
assertEquals(null, ran.toolsetName)
|
||||
assertEquals("memory_save", ran.toolName)
|
||||
assertEquals("base:memory_save:{\"key\":\"value\"}", ran.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inactive tool wins over base fallback for shared name`() = runTest {
|
||||
// Тулу "shared" принадлежит тулсет (inactive), и в base диспетчере тоже
|
||||
// есть "shared". Должен победить тулсет (с auto-activation), не base.
|
||||
val h = harness(
|
||||
listOf(ts("ts", listOf("shared"))),
|
||||
baseKnown = setOf("shared"),
|
||||
)
|
||||
val outcome = h.policy.dispatch("shared", "{}")
|
||||
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||
assertEquals("ts", ran.toolsetName)
|
||||
assertEquals("ok:shared", ran.result)
|
||||
assertTrue(h.registry.isActive("ts"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completely unknown tool bubbles up from base dispatcher`() = runTest {
|
||||
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||
// base dispatcher бросает IllegalStateException — это распространяется
|
||||
// через suspend и попадает в вызывающий код. Это OK: вызывающий код
|
||||
// (ChatAgent) ловит исключения тулов и формирует tool_result с ошибкой.
|
||||
val ex = runCatching {
|
||||
kotlinx.coroutines.runBlocking { h.policy.dispatch("totally_unknown_tool", "{}") }
|
||||
}.exceptionOrNull()
|
||||
assertTrue(ex is IllegalStateException, "expected ISE, got $ex")
|
||||
assertTrue(ex.message!!.contains("totally_unknown_tool"))
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.litert.LiteTool
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ToolsetRegistryTest {
|
||||
|
||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = "ok:$name"
|
||||
}
|
||||
|
||||
private fun contribution(
|
||||
name: String,
|
||||
description: String = "test toolset",
|
||||
toolNames: List<String> = listOf("tool1"),
|
||||
): ToolsetContribution = ToolsetContribution(
|
||||
name = name,
|
||||
description = description,
|
||||
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `empty registry has no active tools`() = runTest {
|
||||
val r = ToolsetRegistry.empty()
|
||||
assertEquals(emptyList(), r.activeNames())
|
||||
assertEquals(emptyList(), r.activeTools())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all returns registered contributions`() {
|
||||
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
|
||||
assertEquals(listOf("a", "b"), r.names())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findByName returns matching contribution or null`() {
|
||||
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
|
||||
assertNotNull(r.findByName("a"))
|
||||
assertEquals("test toolset", r.findByName("a")?.description)
|
||||
assertNull(r.findByName("nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activate changes state and isActive reports true`() = runTest {
|
||||
val r = ToolsetRegistry(listOf(contribution("a")))
|
||||
assertFalse(r.isActive("a"))
|
||||
r.activate("a")
|
||||
assertTrue(r.isActive("a"))
|
||||
assertEquals(listOf("a"), r.activeNames())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activate is idempotent - second call is no-op`() = runTest {
|
||||
val r = ToolsetRegistry(listOf(contribution("a")))
|
||||
r.activate("a")
|
||||
r.activate("a")
|
||||
assertEquals(1, r.activeNames().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deactivate removes from active`() = runTest {
|
||||
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
|
||||
r.activate("a")
|
||||
r.activate("b")
|
||||
r.deactivate("a")
|
||||
assertEquals(listOf("b"), r.activeNames())
|
||||
assertFalse(r.isActive("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deactivate on inactive is no-op`() = runTest {
|
||||
val r = ToolsetRegistry(listOf(contribution("a")))
|
||||
r.deactivate("a") // never activated
|
||||
assertEquals(emptyList(), r.activeNames())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inactiveNames returns the complement of active`() = runTest {
|
||||
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b"), contribution("c")))
|
||||
r.activate("a")
|
||||
r.activate("c")
|
||||
assertEquals(listOf("b"), r.inactiveNames())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activeTools returns the LiteTool instances from active toolsets`() = runTest {
|
||||
val r = ToolsetRegistry(listOf(
|
||||
contribution("ts1", toolNames = listOf("t1", "t2")),
|
||||
contribution("ts2", toolNames = listOf("t3")),
|
||||
))
|
||||
r.activate("ts1")
|
||||
r.activate("ts2")
|
||||
val tools = r.activeTools()
|
||||
assertEquals(3, tools.size)
|
||||
// Проверяем что имена извлекаются из describe()
|
||||
val names = tools.map { it.nameFromDescribe() }.toSet()
|
||||
assertEquals(setOf("t1", "t2", "t3"), names)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findOwnerByToolName locates the owning toolset`() {
|
||||
val r = ToolsetRegistry(listOf(
|
||||
contribution("ts1", toolNames = listOf("t1", "t2")),
|
||||
contribution("ts2", toolNames = listOf("t3")),
|
||||
))
|
||||
val owner = r.findOwnerByToolName("t2")
|
||||
assertNotNull(owner)
|
||||
assertEquals("ts1", owner.first.name)
|
||||
assertEquals("t2", owner.second.toolName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findOwnerByToolName returns null for unknown tool`() {
|
||||
val r = ToolsetRegistry(listOf(contribution("ts1", toolNames = listOf("t1"))))
|
||||
assertNull(r.findOwnerByToolName("nonexistent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close is idempotent and does nothing`() {
|
||||
val r = ToolsetRegistry.empty()
|
||||
r.close()
|
||||
r.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
# `:agentik-cli` — one-shot CLI клиент к `/agentik`
|
||||
|
||||
## Что это
|
||||
|
||||
**One-shot subcommand CLI** (Kotlin Multiplatform) к серверу
|
||||
`:standalone` через `:client` по HTTP+SSE. Один вызов — одна команда:
|
||||
стрим ответа `send` идёт в stdout построчно, никакого embedded-REPL.
|
||||
|
||||
Решает: быстрый способ дёрнуть агента из shell-скрипта или руками,
|
||||
не поднимая отдельную TUI-сессии.
|
||||
|
||||
## Платформы
|
||||
|
||||
| Платформа | Артефакт | Размер | Статус |
|
||||
|---|---|---|---|
|
||||
| `jvm` (JRE 21) | `*-all.jar` | ~7 МБ | ✓ собирается и работает |
|
||||
| `linuxX64` | `.kexe` | ~5 МБ | ✓ собирается и работает на этом хосте |
|
||||
| `macosX64` | `.kexe` | — | собирается на macOS-раннере |
|
||||
| `macosArm64` | `.kexe` | — | собирается на macOS-arm64-раннере |
|
||||
| `mingwX64` | `.exe` | ~6 МБ | ✓ собирается (cross-compile с Linux) |
|
||||
| `linuxArm64` | — | — | **нет** — kotlinx-cli 0.3.6 не публикует klib для linuxArm64 |
|
||||
| `iOS` | — | — | нет смысла на iOS |
|
||||
|
||||
## Подкоманды
|
||||
|
||||
```
|
||||
agentik-cli <command> [args...]
|
||||
|
||||
Команды верхнего уровня:
|
||||
conv <subcommand> операции над диалогами (см. ниже)
|
||||
msgs <id> [--limit N] показать сообщения
|
||||
send <id> <text...> отправить ход, стримит response-события в stdout
|
||||
interrupt <id> прервать текущий ход
|
||||
info показать конфиг (server URL + agent id)
|
||||
|
||||
Подкоманды `conv`:
|
||||
conv ls список диалогов
|
||||
conv new [--temp] создать диалог, печатает id
|
||||
conv show <id> метаданные диалога
|
||||
conv delete <id> удалить диалог
|
||||
conv rename <id> <title> переименовать
|
||||
```
|
||||
|
||||
`--server URL` и `--id ID` (env: `AGENTIK_SERVER`, `AGENTIK_AGENT_ID`)
|
||||
задаются **после** имени subcommand'а — kotlinx.cli не шарит опции
|
||||
родителя в subcommand. Примеры:
|
||||
|
||||
```bash
|
||||
agentik-cli conv ls --server http://192.168.76.166:8080/agentik
|
||||
agentik-cli conv new --server http://localhost:8080/agentik
|
||||
agentik-cli send --server http://localhost:8080/agentik conv-abc "привет"
|
||||
agentik-cli info # через AGENTIK_SERVER env-переменную
|
||||
```
|
||||
|
||||
## Как запустить
|
||||
|
||||
### JVM (fatjar)
|
||||
|
||||
```bash
|
||||
./gradlew :agentik-cli:shadowJar
|
||||
java --enable-native-access=ALL-UNNAMED \
|
||||
-jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar conv --help
|
||||
```
|
||||
|
||||
### Native linuxX64
|
||||
|
||||
```bash
|
||||
./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
|
||||
./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
|
||||
```
|
||||
|
||||
### Native macOS / Windows
|
||||
|
||||
На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
|
||||
macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
|
||||
кросс-компиляцию.
|
||||
|
||||
CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
|
||||
:agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
|
||||
раннере Gitea Actions.
|
||||
|
||||
## Примеры
|
||||
|
||||
```bash
|
||||
# Список диалогов (таблица)
|
||||
agentik-cli conv ls --server http://localhost:8080/agentik
|
||||
|
||||
# Создать диалог
|
||||
ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
|
||||
echo "new conv: $ID"
|
||||
|
||||
# Переименовать
|
||||
agentik-cli conv rename --server http://localhost:8080/agentik "$ID" "мой чат"
|
||||
|
||||
# Отправить ход и стримить ответ
|
||||
agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
|
||||
|
||||
# Показать последние N сообщений
|
||||
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
|
||||
|
||||
# Прервать активный ход
|
||||
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
|
||||
|
||||
# Удалить
|
||||
agentik-cli conv delete --server http://localhost:8080/agentik "$ID"
|
||||
|
||||
# Через env-переменную
|
||||
AGENTIK_SERVER=http://localhost:8080/agentik agentik-cli info
|
||||
```
|
||||
|
||||
## Формат вывода `send`
|
||||
|
||||
Каждое SSE-событие печатается отдельной строкой `event <Type> ...` —
|
||||
пригодно для парсинга через `awk`/`jq`-обёртки:
|
||||
|
||||
```
|
||||
event StartReasoning
|
||||
event StartResponse TEXT
|
||||
event AppendText \n\n
|
||||
event AppendText Привет!
|
||||
event End
|
||||
```
|
||||
|
||||
Терминальные события (`End`, `Interrupted`, `Error`) тоже
|
||||
печатаются; CLI выходит сразу после `End`.
|
||||
|
||||
## Почему kotlinx.cli (а не clikt)
|
||||
|
||||
- **kotlinx.cli 0.3.6** (JetBrains, KMP) — единственный зрелый
|
||||
arg-parser, который стабильно линкуется под `linux_x64` +
|
||||
`macos_x64`/`macos_arm64` + `mingw_x64`. Минус: нет `linux_arm64`.
|
||||
- **clikt-multiplatform 5.x** (ajalt) — имеет linuxArm64, но
|
||||
ломается на native linker: `duplicate symbol selfAndAncestors`
|
||||
между `clikt` и `clikt-mordant` commonMain (issue
|
||||
[ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598)).
|
||||
Workaround `kotlin.native.cacheKind.linuxX64=none` замедляет
|
||||
сборку на порядки и не решает проблему до конца. Поэтому clikt
|
||||
отвергнут.
|
||||
|
||||
## Платформенные детали
|
||||
|
||||
- **entryPoint на K/N** — это FQN функции **без** суффикса `Kt`
|
||||
(т.е. `pw.binom.agentik.cli.main`, а не `MainKt.main`). JVM
|
||||
convention `MainKt.main` тут не работает — K/N линкер ищет
|
||||
функцию по `package.main`.
|
||||
- **`platformEnv(key)`** для чтения env-переменных:
|
||||
- JVM: `System.getenv(key)` через `jvmMain` actual.
|
||||
- Native: `getenv(key)` из `platform.posix` через
|
||||
`kotlinx.cinterop.toKString()` (`nativeMain` actual,
|
||||
требует `@OptIn(ExperimentalForeignApi::class)`).
|
||||
- **Stdout / exit code** — работают на K/N через корутины.
|
||||
|
||||
## Готчасы kotlinx.cli
|
||||
|
||||
- **Вложенные subcommands + parent.execute().** В kotlinx.cli 0.3.6
|
||||
`parent.execute()` вызывается ПОСЛЕ `leaf.execute()` всегда,
|
||||
когда leaf был достигнут через parent. Поэтому `ConvCommand.execute()`
|
||||
сделан no-op (`override fun execute() = Unit`), иначе вывод
|
||||
дочерней команды дублируется выводом родителя. Дочерние команды
|
||||
смотрятся через `agentik-cli conv --help`.
|
||||
- **strictSubcommandOptionsOrder.** Без этого флага `conv new --server ...`
|
||||
парсится как `conv [--server ...]` + позиционный аргумент `new`
|
||||
на уровне родителя — и дочерняя команда не запускается.
|
||||
В `ArgParser` сразу включается `strictSubcommandOptionsOrder = true`.
|
||||
|
||||
## Тесты
|
||||
|
||||
Тесты для подкоманд пока не написаны (TODO). Базовый smoke
|
||||
покрывается руками против живого сервера.
|
||||
|
||||
```bash
|
||||
./gradlew :agentik-cli:jvmTest # 0/0 — пока пусто
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
|
||||
@@ -0,0 +1,94 @@
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// Native-таргеты, которые покрывает kotlinx.cli 0.3.6 (см. его .module
|
||||
// в Maven Central): linux_x64, macos_x64, macos_arm64, mingw_x64.
|
||||
// linuxArm64 не входит — kotlinx.cli 0.3.6 для него не публикуется
|
||||
// (последний релиз 2023-09, KMP-targets зафиксированы). clikt-multiplatform
|
||||
// 5.x имеет linuxArm64, но ломается на duplicate symbol `selfAndAncestors`
|
||||
// между clikt и clikt-mordant при линковке native (issue ajalt/clikt#598),
|
||||
// поэтому clikt отвергнут.
|
||||
//
|
||||
// iOS не входит: :agentik-cli бессмыслен на iOS, а :client (единственный
|
||||
// его потребитель) тоже без iOS.
|
||||
jvm()
|
||||
listOf(
|
||||
linuxX64(),
|
||||
macosX64(),
|
||||
macosArm64(),
|
||||
mingwX64(),
|
||||
)
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":proto"))
|
||||
implementation(project(":client"))
|
||||
|
||||
// kotlinx.cli 0.3.6 — KMP subcommand-парсер от JetBrains.
|
||||
// clikt 5.x имеет upstream-баг: `duplicate symbol selfAndAncestors`
|
||||
// между `clikt` и `clikt-mordant` при линковке native. kotlinx.cli
|
||||
// таких проблем нет.
|
||||
implementation(libs.kotlinx.cli)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
||||
// весь код, включая platformEnv, лежит в commonMain.
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
jvm {
|
||||
binaries {
|
||||
executable {
|
||||
mainClass.set("pw.binom.agentik.cli.AgentikCliKt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
|
||||
// (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
|
||||
// функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
|
||||
// mainClass.set(...) выше.
|
||||
listOf(
|
||||
linuxX64(),
|
||||
macosX64(),
|
||||
macosArm64(),
|
||||
mingwX64(),
|
||||
).forEach {
|
||||
it.binaries.executable {
|
||||
entryPoint = "pw.binom.agentik.cli.main"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fatjar — аналог :standalone.
|
||||
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||
archiveBaseName.set("agentik-cli")
|
||||
archiveClassifier.set("all")
|
||||
description = "Self-contained fatjar with all runtime dependencies bundled."
|
||||
group = "build"
|
||||
|
||||
from(tasks.named("jvmJar"))
|
||||
from(project.configurations.getByName("jvmRuntimeClasspath"))
|
||||
|
||||
mergeServiceFiles()
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
manifest {
|
||||
attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt"
|
||||
attributes["Implementation-Title"] = "agentik-cli"
|
||||
attributes["Implementation-Version"] = project.version.toString()
|
||||
}
|
||||
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package pw.binom.agentik.cli
|
||||
|
||||
import kotlinx.cli.ArgParser
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.ExperimentalCli
|
||||
import kotlinx.cli.Subcommand
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.commands.ConvCommand
|
||||
import pw.binom.agentik.cli.commands.InfoSubcommand
|
||||
import pw.binom.agentik.cli.commands.InterruptSubcommand
|
||||
import pw.binom.agentik.cli.commands.MsgsSubcommand
|
||||
import pw.binom.agentik.cli.commands.SendSubcommand
|
||||
|
||||
/**
|
||||
* Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`.
|
||||
* Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`.
|
||||
*
|
||||
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
|
||||
* `--server`/`--id` (иначе subcommand не видит значения родителя).
|
||||
*/
|
||||
internal fun defaultServerUrl(): String = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||
internal fun defaultAgentId(): String = platformEnv("AGENTIK_AGENT_ID") ?: "cli"
|
||||
|
||||
/**
|
||||
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
|
||||
*
|
||||
* ```
|
||||
* agentik-cli <command> [args...]
|
||||
*
|
||||
* Commands:
|
||||
* conv ls|new|show|delete|rename операции над диалогами
|
||||
* msgs <id> [--limit N] показать сообщения
|
||||
* send <id> <text...> отправить ход, стримит response-события в stdout
|
||||
* interrupt <id> прервать текущий ход
|
||||
* info показать конфиг
|
||||
*
|
||||
* `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е.
|
||||
* `agentik-cli conv ls --server http://...`), не до — kotlinx.cli не
|
||||
* шарит опции родителя в subcommand.
|
||||
*
|
||||
* Вложенные subcommands (`conv ls`, `conv new`, ...) реализованы
|
||||
* через [Subcommand.subcommands]: `conv` сам — subcommand, и его
|
||||
* дочерние команды (`ls`, `new`, `show`, `delete`, `rename`)
|
||||
* регистрируются у него.
|
||||
*/
|
||||
@OptIn(ExperimentalCli::class)
|
||||
fun runAgentikCli(args: Array<String>) {
|
||||
val parser = ArgParser(
|
||||
programName = "agentik-cli",
|
||||
// Все аргументы после имени subcommand должны передаваться
|
||||
// В subcommand-парсер, а не парситься на уровне родителя.
|
||||
// Без этого `conv new --server ...` парсится как `conv [--server ...]`
|
||||
// + аргумент "new" → execute родителя, без вложенной команды.
|
||||
strictSubcommandOptionsOrder = true,
|
||||
)
|
||||
|
||||
val conv = ConvCommand()
|
||||
parser.subcommands(
|
||||
conv,
|
||||
MsgsSubcommand(),
|
||||
SendSubcommand(),
|
||||
InterruptSubcommand(),
|
||||
InfoSubcommand(),
|
||||
)
|
||||
|
||||
parser.parse(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
|
||||
* чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
|
||||
* свойства родителя в subcommand).
|
||||
*/
|
||||
abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
|
||||
val serverUrl: String by option(
|
||||
ArgType.String, fullName = "server", shortName = "s",
|
||||
description = "Base URL агента (env AGENTIK_SERVER)",
|
||||
).default(defaultServerUrl())
|
||||
val agentId: String by option(
|
||||
ArgType.String, fullName = "id", shortName = "i",
|
||||
description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
|
||||
).default(defaultAgentId())
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runAgentikCli(args)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package pw.binom.agentik.cli
|
||||
|
||||
internal expect fun platformEnv(key: String): String?
|
||||
@@ -0,0 +1,32 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ExperimentalCli
|
||||
import kotlinx.cli.Subcommand
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
|
||||
/**
|
||||
* Родительская группа `conv`: операции над диалогами.
|
||||
*
|
||||
* Сама команда `agentik-cli conv` (без подкоманды) — no-op:
|
||||
* в kotlinx.cli parent.execute() вызывается ПОСЛЕ leaf.execute(),
|
||||
* поэтому любая работа в execute() дублирует вывод дочерней команды.
|
||||
* Для просмотра дочерних команд есть `agentik-cli conv --help`.
|
||||
*
|
||||
* Дочерние команды регистрируются через [subcommands] в конструкторе.
|
||||
*/
|
||||
@OptIn(ExperimentalCli::class)
|
||||
class ConvCommand : Subcommand("conv", "Операции над диалогами") {
|
||||
init {
|
||||
subcommands(
|
||||
ConvLsSubcommand(),
|
||||
ConvNewSubcommand(),
|
||||
ConvShowSubcommand(),
|
||||
ConvDeleteSubcommand(),
|
||||
ConvRenameSubcommand(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun execute() = Unit
|
||||
}
|
||||
|
||||
abstract class ConvSubcommand(name: String, description: String) : AgentikSubcommand(name, description)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val ok = agent.deleteConversation(id)
|
||||
if (ok) println("deleted: $id") else println("conversation not found: $id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов агента") {
|
||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
||||
if (convs.isEmpty()) {
|
||||
println("(no conversations)")
|
||||
return@runBlocking
|
||||
}
|
||||
println("ID UPDATED-AT TITLE FLAGS")
|
||||
convs.forEach { c ->
|
||||
val flags = buildString {
|
||||
if (c.isTemporal) append('T')
|
||||
if (c.isSupportImageInput) append('I')
|
||||
if (c.isSupportImageOutput) append('O')
|
||||
if (isEmpty()) append('-')
|
||||
}
|
||||
val title = c.title ?: "(untitled)"
|
||||
println("${c.id.padEnd(38)} ${c.updatedAt.toString().padEnd(22)} ${title.take(30).padEnd(31)} $flags")
|
||||
}
|
||||
println("--- ${convs.size} conversation(s)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
||||
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.createConversation(temp = temp)
|
||||
println(conv.id)
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
val title by argument(ArgType.String, description = "Новое название")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
conv.rename(title)
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
println("renamed: $id -> $title")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
println("id: ${conv.id}")
|
||||
println("title: ${conv.title ?: "(untitled)"}")
|
||||
println("updatedAt: ${conv.updatedAt}")
|
||||
println("isTemporal: ${conv.isTemporal}")
|
||||
println("isSupportImageInput: ${conv.isSupportImageInput}")
|
||||
println("isSupportImageOutput: ${conv.isSupportImageOutput}")
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
|
||||
class InfoSubcommand : AgentikSubcommand("info", "Показать server URL и agent id") {
|
||||
override fun execute() {
|
||||
println("server: $serverUrl")
|
||||
println("id: $agentId")
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
conv.interrupt()
|
||||
println("interrupted: $id")
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Message
|
||||
import kotlin.time.Instant
|
||||
|
||||
class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообщения диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
val msgs = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = limit)
|
||||
.sortedBy { it.date }
|
||||
msgs.forEach { m -> println(formatMessage(m)) }
|
||||
println("--- ${msgs.size} message(s)")
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMessage(m: Message): String =
|
||||
"[${m.date}] ${m.role().padEnd(11)} ${m.bodyOneLine()}"
|
||||
|
||||
private fun Message.role(): String = when (this) {
|
||||
is Message.UserMessage -> "[user]"
|
||||
is Message.AssistantMessage -> "[assistant]"
|
||||
is Message.ToolCall -> "[tool_call]"
|
||||
is Message.ToolResult -> "[tool_result]"
|
||||
is Message.Error -> "[error]"
|
||||
}
|
||||
|
||||
private fun Message.bodyOneLine(): String = when (this) {
|
||||
is Message.UserMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||
is Message.AssistantMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||
is Message.ToolCall -> "tool=$toolName args=$toolArgs"
|
||||
is Message.ToolResult -> "id=$id result=${result ?: "<null>"}"
|
||||
is Message.Error -> "code=${code ?: "?"} message=$message"
|
||||
}
|
||||
|
||||
private fun Content.toOneLine(): String = when (this) {
|
||||
is Content.Text -> body.replace('\n', ' ').take(200)
|
||||
is Content.Image -> "<image ${data.size}B $mime>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.vararg
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
// Подписываемся на поток событий ДО send: события, отправленные
|
||||
// до подписки, не реплеятся (shared-flow без replay).
|
||||
val eventsJob = launch {
|
||||
conv.events(Instant.DISTANT_PAST)
|
||||
// onEach печатает и терминальный event, takeWhile лишь
|
||||
// завершает сбор после него.
|
||||
.onEach { ev -> emit(ev) }
|
||||
.takeWhile { ev -> !isTerminal(ev) }
|
||||
.collect { }
|
||||
}
|
||||
// Даём SSE-подписке установиться, затем шлём ход.
|
||||
delay(200)
|
||||
conv.send(listOf(Content.Text(text.joinToString(" "))))
|
||||
eventsJob.join()
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTerminal(ev: Event): Boolean =
|
||||
ev is Event.End || ev is Event.Interrupted || ev is Event.Error
|
||||
|
||||
private fun emit(ev: Event) {
|
||||
when (ev) {
|
||||
is Event.StartReasoning -> println("event StartReasoning")
|
||||
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
|
||||
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
|
||||
is Event.End -> println("event End")
|
||||
is Event.Interrupted -> println("event Interrupted")
|
||||
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package pw.binom.agentik.cli
|
||||
|
||||
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||
@@ -0,0 +1,8 @@
|
||||
package pw.binom.agentik.cli
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.toKString
|
||||
import platform.posix.getenv
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal actual fun platformEnv(key: String): String? = getenv(key)?.toKString()
|
||||
@@ -0,0 +1,103 @@
|
||||
# `:agentik-tui` — Compose-for-Mosaic TUI-клиент к `/agentik`
|
||||
|
||||
## Что это
|
||||
|
||||
Compose-style TUI-клиент в терминале на базе
|
||||
[Mosaic](https://github.com/JakeWharton/mosaic) (Jetpack Compose
|
||||
runtime, рендерится в ANSI-коды). Без `:`-команд (без vim-style
|
||||
prompt): клавиатурная навигация Tab/Enter/Esc/Ctrl-D/F1/стрелки +
|
||||
жирный focus indicator.
|
||||
|
||||
- **Layout**: header (id/conv/focus) + history + input + footer.
|
||||
- **Focus**: Tab/Shift-Tab цикл по фокусам (input → history → sidebar).
|
||||
- **Input**: стандартное текстовое поле с курсором `|` посередине.
|
||||
- **Stream**: подписка на SSE в фон-корутинах, `StateFlow` + `collectAsState()`
|
||||
для UI-реактивности (см. Snake sample).
|
||||
|
||||
Решает: полноценный TUI-клиент для тех, кто предпочитает мышкой
|
||||
кликать в терминале больше, чем печатать. В отличие от `:agentik-cli`,
|
||||
показывает историю диалога и текущий стрим в одном окне.
|
||||
|
||||
## Как запустить
|
||||
|
||||
### Требования
|
||||
|
||||
- JVM 21+.
|
||||
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
|
||||
- Реальный TTY (через `ssh -tt`, `tmux`, либо нативный terminal).
|
||||
|
||||
### Запуск из готового fatjar
|
||||
|
||||
```bash
|
||||
java --enable-native-access=ALL-UNNAMED \
|
||||
-jar agentik-tui-0.1.0-all.jar \
|
||||
--server http://192.168.76.166:8080/agentik
|
||||
```
|
||||
|
||||
`--enable-native-access=ALL-UNNAMED` обязателен — Mosaic использует
|
||||
native syscalls для терминала.
|
||||
|
||||
### Запуск через Gradle (dev)
|
||||
|
||||
```bash
|
||||
./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
|
||||
```
|
||||
|
||||
## Параметры CLI
|
||||
|
||||
| Флаг | ENV | Что делает |
|
||||
|---|---|---|
|
||||
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
|
||||
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
|
||||
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
|
||||
| `--help` | — | Показывает help и выходит |
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Клавиша | Когда | Что делает |
|
||||
|---|---|---|
|
||||
| `Tab` / `Shift-Tab` | глобально | Цикл фокусов: input → history → sidebar → ... |
|
||||
| `F1` | глобально | Toggle help overlay |
|
||||
| `Esc` | в input | Очистить input |
|
||||
| `Enter` | в input | Submit message |
|
||||
| `Backspace` / `Del` | в input | Удалить символ |
|
||||
| `←` `→` `Home` `End` | в input | Курсор |
|
||||
| `↑` `↓` | в history | Scrollback |
|
||||
| `Ctrl-D` / `Ctrl-C` | — | Exit (TODO — пока работает только вне стрима) |
|
||||
|
||||
## Переменные среды (сервера)
|
||||
|
||||
См. [`../standalone/README.md`](../standalone/README.md). TUI
|
||||
получает URL сервера через `--server`, остальное настройка
|
||||
агента, а не клиента.
|
||||
|
||||
## Известное ограничение
|
||||
|
||||
1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
|
||||
же, что для `:agentik-cli`.
|
||||
2. **Mouse events не подключены** в v2 (Mosaic 0.18 не имеет
|
||||
built-in mouse-runtime). Планируется в v3 через termios
|
||||
SGR-mouse.
|
||||
3. **Нативные target'ы (macOS / Linux x64+ARM64 / Windows x64)**
|
||||
собраны, но без `:client` (он JVM-only). Для нативной работы
|
||||
нужен альтернативный HTTP-клиент.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :agentik-tui:jvmTest
|
||||
```
|
||||
|
||||
Тесты composable'ов и event-рендеринга. Включает smoke-test для
|
||||
key-event → AppState mutation → ре-рендер.
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-agentik-tui`.
|
||||
|
||||
## Архитектурная заметка
|
||||
|
||||
UI-стейт держится в `StateFlow`, а **не** в Compose `mutableStateOf`.
|
||||
Причина: Mosaic 0.18 не триггерит recomposition от `mutableStateOf`
|
||||
-writes внутри `onPreviewKeyEvent`-handler'ов (см. Snake sample в
|
||||
репо Mosaic — они тоже используют `StateFlow` + `collectAsState()`).
|
||||
@@ -0,0 +1,110 @@
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
// Suppress Beta-предупреждения от expect/actual объектов.
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||
}
|
||||
|
||||
// Mosaic 0.18 поддерживает JVM + desktop-native (macosX64/macosArm64/linuxX64/linuxArm64/mingwX64).
|
||||
// iOS пропускаем — на iOS не бывает TUI-сессий.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
// Native executables. По умолчанию Kotlin/Native для каждого target'а
|
||||
// собирает только .klib (библиотеку) — для запускаемого .kexe надо
|
||||
// явно попросить binaries.executable(). entryPoint нужно задать явно:
|
||||
// KMP-линкер ищет функцию по FQN (без `Kt`-суффикса), а Kotlin/Native
|
||||
// добавляет суффикс только для файлов с именем `Main.kt`, поэтому
|
||||
// указываем точку входа как `pw.binom.agentik.tui.main` (без суффикса).
|
||||
//
|
||||
// Применяем к каждому из linuxX64/macosX64/macosArm64/linuxArm64/mingwX64
|
||||
// явно (а не через targets.withType), потому что targets DSL в KMP не
|
||||
// поддерживает реифицированный withType<KotlinNativeTarget>().
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
listOf(linuxX64(), linuxArm64(), macosX64(), macosArm64(), mingwX64()).forEach {
|
||||
it.binaries.executable {
|
||||
entryPoint = "pw.binom.agentik.tui.main"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":proto"))
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// JetBrains Compose runtime — тащит Mosaic как обёртку.
|
||||
implementation(libs.mosaic.runtime)
|
||||
implementation(libs.mosaic.tty.terminal)
|
||||
|
||||
// Health-check в Main.kt: Ktor CIO на JVM, на native не собирается —
|
||||
// там работает stub actual через expect/actual.
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(project(":client"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
jvm {
|
||||
binaries {
|
||||
executable {
|
||||
mainClass.set("pw.binom.agentik.tui.MainKt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fatjar (uberjar) ---
|
||||
//
|
||||
// Аналогично `:agentik-cli`: shadowJar склеивает `jvmJar` + `jvmRuntimeClasspath` в self-contained
|
||||
// `*-all.jar`. Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — регистрируем явно.
|
||||
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||
archiveBaseName.set("agentik-tui")
|
||||
archiveClassifier.set("all")
|
||||
description = "Self-contained fatjar with all runtime dependencies bundled (incl. Compose-runtime + Mosaic)."
|
||||
group = "build"
|
||||
|
||||
from(tasks.named("jvmJar"))
|
||||
val cc = try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||
} catch (_: ClassCastException) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||
}
|
||||
from(cc.getByName("jvmRuntimeClasspath"))
|
||||
|
||||
mergeServiceFiles()
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
manifest {
|
||||
attributes["Main-Class"] = "pw.binom.agentik.tui.MainKt"
|
||||
attributes["Implementation-Title"] = "agentik-tui"
|
||||
attributes["Implementation-Version"] = project.version.toString()
|
||||
}
|
||||
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Row
|
||||
import pw.binom.agentik.tui.ui.Footer
|
||||
import pw.binom.agentik.tui.ui.Header
|
||||
import pw.binom.agentik.tui.ui.HelpOverlay
|
||||
import pw.binom.agentik.tui.ui.HistoryPanel
|
||||
import pw.binom.agentik.tui.ui.InputLine
|
||||
|
||||
/**
|
||||
* Корневая Compose-композиция TUI. Содержит только каркас + глобальный key-handler;
|
||||
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
|
||||
*
|
||||
* Layout (минимальный):
|
||||
* ```
|
||||
* ┌─────────────────────────────────────────────────────────┐
|
||||
* │ HEADER: agentik · id · conv-id · focus=… │
|
||||
* ├─────────────────────────────────────────────────────────┤
|
||||
* │ HISTORY (весь актуальный диалог) │
|
||||
* ├─────────────────────────────────────────────────────────┤
|
||||
* │ INPUT LINE: > text| │
|
||||
* ├─────────────────────────────────────────────────────────┤
|
||||
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||
* └─────────────────────────────────────────────────────────┘
|
||||
* ```
|
||||
*
|
||||
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
|
||||
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
|
||||
*/
|
||||
@Composable
|
||||
internal fun App(state: AppState) {
|
||||
val focusIndex by state.focusIndex.collectAsState()
|
||||
val showHelp by state.showHelp.collectAsState()
|
||||
|
||||
Row(modifier = Modifier.onPreviewKeyEvent { ev ->
|
||||
when (ev.key) {
|
||||
"Tab" -> { state.cycleFocus(direction = if (ev.shift) -1 else +1); true }
|
||||
"F1" -> { state.toggleHelp(); true }
|
||||
"Escape", "Esc" -> {
|
||||
if (showHelp) state.setShowHelp(false)
|
||||
else if (focusIndex == 0) state.inputClear()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Header(state, focusIndex)
|
||||
HistoryPanel(state)
|
||||
InputLine(state)
|
||||
Footer(showHelp)
|
||||
}
|
||||
}
|
||||
if (showHelp) HelpOverlay()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Состояние TUI. По дизайну — singleton, переживает все экраны.
|
||||
*
|
||||
* Используем [StateFlow] вместо Compose [androidx.compose.runtime.mutableStateOf]
|
||||
* потому что в Mosaic 0.18 recomposition от `mutableStateOf`-writes из key-event
|
||||
* handlers работает нестабильно (требует ручного [androidx.compose.runtime.Snapshot]
|
||||
* apply). `StateFlow` + `collectAsState()` — работает out-of-the-box
|
||||
* (см. samples/snake в репо Mosaic).
|
||||
*/
|
||||
internal class AppState(val config: TuiConfig) {
|
||||
/** Бэкенд, прикреплённый из TuiApp — маршрутизирует submitInput → send. */
|
||||
private var backend: TuiBackend? = null
|
||||
fun attachBackend(b: TuiBackend) { backend = b }
|
||||
|
||||
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
|
||||
private val _focusIndex = MutableStateFlow(0)
|
||||
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
|
||||
|
||||
/** Видимость help-оверлея. */
|
||||
private val _showHelp = MutableStateFlow(false)
|
||||
val showHelp: StateFlow<Boolean> = _showHelp.asStateFlow()
|
||||
|
||||
/** Сообщения диалога. */
|
||||
private val _messages = MutableStateFlow<List<TuiMessage>>(emptyList())
|
||||
val messages: StateFlow<List<TuiMessage>> = _messages.asStateFlow()
|
||||
|
||||
/** Заголовок текущего диалога. */
|
||||
private val _currentTitle = MutableStateFlow<String?>(null)
|
||||
val currentTitle: StateFlow<String?> = _currentTitle.asStateFlow()
|
||||
|
||||
/** ID текущего диалога. */
|
||||
private val _currentConversationId = MutableStateFlow<String?>(null)
|
||||
val currentConversationId: StateFlow<String?> = _currentConversationId.asStateFlow()
|
||||
|
||||
/** Список диалогов (sidebar). */
|
||||
private val _conversations = MutableStateFlow<List<ConvSummary>>(emptyList())
|
||||
val conversations: StateFlow<List<ConvSummary>> = _conversations.asStateFlow()
|
||||
|
||||
/** Курсор в списке диалогов. */
|
||||
private val _conversationsCursor = MutableStateFlow(0)
|
||||
val conversationsCursor: StateFlow<Int> = _conversationsCursor.asStateFlow()
|
||||
|
||||
/** Поле ввода. */
|
||||
private val _input = MutableStateFlow("")
|
||||
val input: StateFlow<String> = _input.asStateFlow()
|
||||
|
||||
/** Курсор в input (offset в chars). */
|
||||
private val _cursor = MutableStateFlow(0)
|
||||
val cursor: StateFlow<Int> = _cursor.asStateFlow()
|
||||
|
||||
/** Идёт ли стрим. */
|
||||
private val _streaming = MutableStateFlow(false)
|
||||
val streaming: StateFlow<Boolean> = _streaming.asStateFlow()
|
||||
|
||||
/** Scrollback index: 0 = прижат к низу. */
|
||||
private val _historyScroll = MutableStateFlow(0)
|
||||
val historyScroll: StateFlow<Int> = _historyScroll.asStateFlow()
|
||||
|
||||
// ---------- мутации ----------
|
||||
|
||||
fun cycleFocus(direction: Int = +1) {
|
||||
_focusIndex.value = (_focusIndex.value + direction).mod(3)
|
||||
}
|
||||
|
||||
fun toggleHelp() { _showHelp.value = !_showHelp.value }
|
||||
fun setShowHelp(v: Boolean) { _showHelp.value = v }
|
||||
|
||||
fun inputInsert(s: String) {
|
||||
val pos = _cursor.value.coerceIn(0, _input.value.length)
|
||||
_input.value = _input.value.substring(0, pos) + s + _input.value.substring(pos)
|
||||
_cursor.value = pos + s.length
|
||||
}
|
||||
|
||||
fun inputBackspace() {
|
||||
val pos = _cursor.value
|
||||
if (pos <= 0) return
|
||||
_input.value = _input.value.substring(0, pos - 1) + _input.value.substring(pos)
|
||||
_cursor.value = pos - 1
|
||||
}
|
||||
|
||||
fun inputDelete() {
|
||||
val pos = _cursor.value
|
||||
if (pos >= _input.value.length) return
|
||||
_input.value = _input.value.substring(0, pos) + _input.value.substring(pos + 1)
|
||||
}
|
||||
|
||||
fun inputClear() { _input.value = ""; _cursor.value = 0 }
|
||||
|
||||
fun inputMoveCursor(delta: Int) {
|
||||
_cursor.value = (_cursor.value + delta).coerceIn(0, _input.value.length)
|
||||
}
|
||||
fun inputCursorHome() { _cursor.value = 0 }
|
||||
fun inputCursorEnd() { _cursor.value = _input.value.length }
|
||||
|
||||
fun submitInput(): String? {
|
||||
val text = _input.value.trim()
|
||||
if (text.isEmpty()) return null
|
||||
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
|
||||
inputClear()
|
||||
_streaming.value = true
|
||||
backend?.onUserMessage(text)
|
||||
return text
|
||||
}
|
||||
|
||||
fun setStreaming(v: Boolean) { _streaming.value = v }
|
||||
|
||||
fun setConversation(id: String, title: String?) {
|
||||
_currentConversationId.value = id
|
||||
_currentTitle.value = title
|
||||
_messages.value = emptyList()
|
||||
_historyScroll.value = 0
|
||||
_streaming.value = false
|
||||
}
|
||||
|
||||
fun postToolCall(toolName: String, title: String?, args: String) {
|
||||
_messages.value = _messages.value + TuiMessage.ToolCall(toolName = toolName, title = title, args = args, ts = nowInstant())
|
||||
}
|
||||
|
||||
fun postToolResult(toolName: String, result: String) {
|
||||
_messages.value = _messages.value + TuiMessage.ToolResult(toolName = toolName, result = result, ts = nowInstant())
|
||||
}
|
||||
|
||||
fun appendAssistant(chunk: String) {
|
||||
val list = _messages.value.toMutableList()
|
||||
val last = list.lastOrNull()
|
||||
if (last is TuiMessage.AssistantStreaming) {
|
||||
list[list.lastIndex] = last.copy(text = last.text + chunk)
|
||||
} else {
|
||||
list.add(TuiMessage.AssistantStreaming(text = chunk, ts = nowInstant()))
|
||||
}
|
||||
_messages.value = list
|
||||
}
|
||||
|
||||
fun finishAssistant() {
|
||||
val list = _messages.value.toMutableList()
|
||||
val last = list.lastOrNull() ?: return
|
||||
if (last is TuiMessage.AssistantStreaming) {
|
||||
list[list.lastIndex] = TuiMessage.Assistant(text = last.text, ts = last.ts)
|
||||
_messages.value = list
|
||||
}
|
||||
_streaming.value = false
|
||||
}
|
||||
|
||||
fun newConversation(id: String, title: String?) {
|
||||
_currentConversationId.value = id
|
||||
_currentTitle.value = title
|
||||
_messages.value = emptyList()
|
||||
_historyScroll.value = 0
|
||||
_streaming.value = false
|
||||
}
|
||||
|
||||
fun postSystem(text: String) {
|
||||
_messages.value = _messages.value + TuiMessage.System(text = text, ts = nowInstant())
|
||||
}
|
||||
}
|
||||
|
||||
/** Снимок диалога для sidebar. */
|
||||
internal data class ConvSummary(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
/** Рендер-единица. */
|
||||
internal sealed interface TuiMessage {
|
||||
val ts: Instant
|
||||
|
||||
data class System(val text: String, override val ts: Instant) : TuiMessage
|
||||
data class User(val text: String, override val ts: Instant) : TuiMessage
|
||||
data class AssistantStreaming(val text: String, override val ts: Instant) : TuiMessage
|
||||
data class Assistant(val text: String, override val ts: Instant) : TuiMessage
|
||||
data class ToolCall(val toolName: String, val title: String?, val args: String, override val ts: Instant) : TuiMessage
|
||||
data class ToolResult(val toolName: String, val result: String, override val ts: Instant) : TuiMessage
|
||||
}
|
||||
|
||||
internal fun nowInstant(): Instant = kotlin.time.Clock.System.now()
|
||||
@@ -0,0 +1,161 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
* Точка входа TUI-клиента agentik.
|
||||
*
|
||||
* ```
|
||||
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
|
||||
* ```
|
||||
*
|
||||
* Перед запуском UI — обязательный health-check: `GET {server}/health`.
|
||||
* Если сервер недоступен — печатаем понятную ошибку и выходим с кодом 1.
|
||||
* Если OK — создаём [Agent] через платформенную actual и запускаем
|
||||
* [TuiApp].
|
||||
*/
|
||||
fun main(args: Array<String>) = runBlocking {
|
||||
val cfg = parseCliArgs(args) ?: run {
|
||||
printUsage()
|
||||
return@runBlocking
|
||||
}
|
||||
checkServer(cfg.server)
|
||||
val agent = platformCreateAgent(cfg.server, cfg.id)
|
||||
TuiApp(cfg, agent).run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Делает синхронный GET `{baseUrl}/health`. Внутри [route(path)] на сервере
|
||||
* `/health` зарегистрирован под тем же path-prefix'ом, что и сам API
|
||||
* (например, baseUrl = `http://localhost:8080/agentik` → health = …/agentik/health).
|
||||
*
|
||||
* При любой ошибке (connect refused, timeout, не-200 ответ, не `"ok"`) —
|
||||
* бросает [IllegalStateException] с понятным сообщением. [runBlocking]-обёртка
|
||||
* в [main] разворачивает её в stack-trace и `exit 1`.
|
||||
*/
|
||||
private suspend fun checkServer(baseUrl: String) {
|
||||
val healthUrl = "${baseUrl.trimEnd('/')}/health"
|
||||
val client = HttpClient(CIO) {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 5_000
|
||||
connectTimeoutMillis = 3_000
|
||||
}
|
||||
expectSuccess = false
|
||||
}
|
||||
try {
|
||||
val response = client.get(healthUrl)
|
||||
if (response.status.value !in 200..299) {
|
||||
throw IllegalStateException("сервер ответил HTTP ${response.status.value} на GET $healthUrl")
|
||||
}
|
||||
val body = response.bodyAsText().trim()
|
||||
if (body != "ok") {
|
||||
throw IllegalStateException("сервер ответил неожиданным телом на GET $healthUrl: '$body'")
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// На JVM сюда упадут java.net.ConnectException, UnknownHostException,
|
||||
// io.ktor.client.network.sockets.ConnectTimeoutException и т.п.
|
||||
// На нативе native stub падает раньше в platformCreateAgent, так что
|
||||
// сюда мы попадём только под JVM-actual.
|
||||
throw IllegalStateException(
|
||||
"ошибка health-check $healthUrl: ${e::class.simpleName} — ${e.message ?: "(нет сообщения)"}",
|
||||
e,
|
||||
)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация TUI, вычисленная из аргументов + переменных среды.
|
||||
* Доступна из других файлов commonMain как `internal`.
|
||||
*/
|
||||
internal data class TuiConfig(
|
||||
val server: String,
|
||||
val id: String,
|
||||
val historyEnabled: Boolean,
|
||||
)
|
||||
|
||||
private fun parseCliArgs(args: Array<String>): TuiConfig? {
|
||||
var server: String? = null
|
||||
var id: String? = null
|
||||
var historyEnabled = true
|
||||
|
||||
var i = 0
|
||||
while (i < args.size) {
|
||||
when (val a = args[i]) {
|
||||
"--help", "-h", "help" -> return null
|
||||
"--server", "-s" -> {
|
||||
require(i + 1 < args.size) { "$a требует URL" }
|
||||
server = args[i + 1]; i += 2
|
||||
}
|
||||
"--id" -> {
|
||||
require(i + 1 < args.size) { "$a требует значение" }
|
||||
id = args[i + 1]; i += 2
|
||||
}
|
||||
"--no-history" -> { historyEnabled = false; i++ }
|
||||
"--" -> i++
|
||||
else -> error("неизвестный аргумент: $a (введите --help)")
|
||||
}
|
||||
}
|
||||
|
||||
val envServer = platformEnv("AGENTIK_SERVER")
|
||||
val envUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||
val resolvedServer = server ?: envServer ?: "http://localhost:8080/agentik"
|
||||
val resolvedId = id ?: "cli-tui:${envUser}"
|
||||
|
||||
return TuiConfig(
|
||||
server = resolvedServer,
|
||||
id = resolvedId,
|
||||
historyEnabled = historyEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает переменную среды. JVM actual — `System.getenv`, native actual — `getenv()` через cinterop.
|
||||
* Доступ к environment делается через expect/actual, чтобы commonMain не тащил JVM-пакеты.
|
||||
*/
|
||||
internal expect fun platformEnv(key: String): String?
|
||||
|
||||
/**
|
||||
* Создаёт платформенную реализацию [Agent]. JVM actual подключает `:client`
|
||||
* и ходит в HTTP-фасад; native actual пока возвращает stub (см. Platform.native.kt).
|
||||
*/
|
||||
internal expect fun platformCreateAgent(baseUrl: String, id: String): Agent
|
||||
|
||||
private fun printUsage() {
|
||||
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||
|
||||
println("""
|
||||
agentik-tui — Compose-Mosaic UI поверх протокола agentik
|
||||
|
||||
Использование:
|
||||
agentik-tui [--server URL] [--id ID] [--no-history]
|
||||
|
||||
Аргументы:
|
||||
--server, -s URL базовый URL (default: $defaultServer)
|
||||
--id ID идентификатор клиента (default: cli-tui:${defaultUser})
|
||||
--no-history не сохранять состояние
|
||||
--help, -h эта справка
|
||||
|
||||
Переменные среды:
|
||||
AGENTIK_SERVER базовый URL (эквивалент --server)
|
||||
USER / USERNAME используется в id клиента по умолчанию
|
||||
|
||||
В UI:
|
||||
Tab / Shift-Tab переключить фокус между историей и вводом
|
||||
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
||||
← / → двинуть курсор в инпуте
|
||||
Enter отправить сообщение (создаст новый диалог, если их нет)
|
||||
Ctrl-C / Ctrl-D выйти
|
||||
F1 показать подсказки по горячим клавишам
|
||||
""".trimIndent())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import com.jakewharton.mosaic.runMosaicBlocking
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
* Корневая точка запуска UI. Стартует Mosaic-рантайм, монтирует [TuiBackend] в
|
||||
* его coroutine-scope и ждёт завершения приложения.
|
||||
*
|
||||
* Бэкенд — единый singleton на процесс: UI-композиция, сетевые подписки и
|
||||
* coroutine job'ы делят scope [runMosaicBlocking] (через [LaunchedEffect]).
|
||||
*/
|
||||
internal class TuiApp(
|
||||
private val config: TuiConfig,
|
||||
private val agent: Agent,
|
||||
) {
|
||||
fun run() {
|
||||
runMosaicBlocking {
|
||||
val state = remember { AppState(config) }
|
||||
val backend = remember { TuiBackend(state = state, agent = agent) }
|
||||
LaunchedEffect(backend) {
|
||||
backend.start(this)
|
||||
}
|
||||
state.attachBackend(backend)
|
||||
App(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Backend-логика TUI: мост между [Agent] и [AppState].
|
||||
*
|
||||
* Жизненный цикл:
|
||||
* 1. На старте [start] — health-check сделан в [Main] ДО Mosaic; здесь только
|
||||
* пост-сообщение "connected to …".
|
||||
* 2. Подписка на [Agent.events] — обновление списка диалогов в sidebar.
|
||||
* 3. При [onUserMessage] — если текущего диалога нет, создаём
|
||||
* [createConversation] (temp=false, чтобы он персистился на сервере), затем
|
||||
* [send]. Подписка на [Conversation.events] идёт сразу при создании/открытии.
|
||||
*
|
||||
* Дизайн: один backend-объект на процесс, живёт в [runMosaicBlocking]-scope.
|
||||
*/
|
||||
internal class TuiBackend(
|
||||
private val state: AppState,
|
||||
private val agent: Agent,
|
||||
) {
|
||||
/** Текущий открытый диалог, либо `null`, если ещё не выбран. */
|
||||
private var current: Conversation? = null
|
||||
|
||||
/** Активная джоба подписки на [Conversation.events]. */
|
||||
private var eventsJob: Job? = null
|
||||
|
||||
/** Последний виденный момент событий — для переподписки при reconnect. */
|
||||
private var lastSeenAt: Instant = Instant.DISTANT_PAST
|
||||
|
||||
/**
|
||||
* Запускает фоновые подписки в scope [scope] (передаётся из Mosaic
|
||||
* LaunchedEffect'а — это scope recomposer'а, живёт до закрытия UI).
|
||||
*/
|
||||
fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
state.postSystem("подключено к ${state.config.server}")
|
||||
scope.launch {
|
||||
try {
|
||||
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||
// штатная отмена при закрытии UI
|
||||
} catch (e: Exception) {
|
||||
state.postSystem("ошибка live-events: ${e.message ?: e::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
/**
|
||||
* Обработка пользовательского сообщения, отправленного из input.
|
||||
*
|
||||
* Если текущего диалога нет — создаём его; затем `send`. Подписка на
|
||||
* события конкретного диалога стартует в [ensureConversation].
|
||||
*/
|
||||
fun onUserMessage(text: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val conv = ensureConversation()
|
||||
conv.send(listOf(Content.Text(text)))
|
||||
} catch (e: Exception) {
|
||||
state.postSystem("ошибка отправки: ${e.message ?: e::class.simpleName}")
|
||||
state.setStreaming(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт [Conversation], если ещё не было; открывает подписку на её события.
|
||||
*/
|
||||
private suspend fun ensureConversation(): Conversation {
|
||||
current?.let { return it }
|
||||
val conv = agent.createConversation(temp = false)
|
||||
state.setConversation(id = conv.id, title = conv.title)
|
||||
subscribeEvents(conv, Instant.DISTANT_PAST)
|
||||
current = conv
|
||||
return conv
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывается на [Conversation.events] и перенаправляет их в [state].
|
||||
*/
|
||||
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||
eventsJob?.cancel()
|
||||
eventsJob = scope.launch {
|
||||
conv.events(from).collect { ev -> dispatch(ev) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг [Event] → [AppState] (что показать в TUI).
|
||||
*
|
||||
* - AppendText → дописывает в последний ассистентский чанк
|
||||
* - StartReasoning / StartResponse → новый streaming-чанк
|
||||
* - End → закрывает streaming
|
||||
* - Interrupted → закрывает streaming + системное сообщение
|
||||
* - ToolCall / ToolResult → сообщения в историю
|
||||
* - Error → системное сообщение
|
||||
*/
|
||||
private fun dispatch(ev: Event) {
|
||||
lastSeenAt = ev.date
|
||||
when (ev) {
|
||||
is Event.AppendText -> state.appendAssistant(ev.body)
|
||||
is Event.StartReasoning -> {
|
||||
state.postSystem("… думаю")
|
||||
}
|
||||
is Event.StartResponse -> state.setStreaming(true)
|
||||
is Event.End -> state.finishAssistant()
|
||||
is Event.Interrupted -> {
|
||||
state.finishAssistant()
|
||||
state.postSystem("прервано")
|
||||
}
|
||||
is Event.AppendImage -> {
|
||||
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
|
||||
}
|
||||
is Event.ToolCall -> {
|
||||
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
|
||||
}
|
||||
is Event.ToolResult -> {
|
||||
state.postToolResult(toolName = "", result = ev.result ?: "")
|
||||
}
|
||||
is Event.Error -> {
|
||||
state.setStreaming(false)
|
||||
state.postSystem("ошибка: ${ev.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
|
||||
/**
|
||||
* Нижняя подсказка с текущим набором горячих клавиш.
|
||||
*
|
||||
* При открытом help-оверлее показывает заглушку с указателем «наверху».
|
||||
*/
|
||||
@Composable
|
||||
internal fun Footer(showHelp: Boolean) {
|
||||
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
||||
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
||||
Text(value = hint, textStyle = TextStyle.Italic)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
import pw.binom.agentik.tui.AppState
|
||||
|
||||
/**
|
||||
* Верхняя инвертированная полоса с идентификатором и текущим фокусом.
|
||||
*
|
||||
* Пример: ` agentik · cli-tui:root · a1b2c3d4… · мой чат · focus=input `
|
||||
*/
|
||||
@Composable
|
||||
internal fun Header(state: AppState, focusIndex: Int) {
|
||||
val title by state.currentTitle.collectAsState()
|
||||
val convId by state.currentConversationId.collectAsState()
|
||||
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
||||
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
||||
val titleStr = title ?: "(нет диалога)"
|
||||
Text(
|
||||
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
||||
textStyle = TextStyle.Bold + TextStyle.Invert,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
|
||||
/**
|
||||
* Полноэкранный оверлей со списком горячих клавиш.
|
||||
* Включается/выключается по F1 (см. [pw.binom.agentik.tui.App]).
|
||||
*/
|
||||
@Composable
|
||||
internal fun HelpOverlay() {
|
||||
Column(modifier = Modifier) {
|
||||
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
||||
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
||||
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
||||
Text(value = " ← / → курсор в input")
|
||||
Text(value = " Enter отправить сообщение")
|
||||
Text(value = " Backspace / Del удалить символ")
|
||||
Text(value = " Esc очистить input")
|
||||
Text(value = " Ctrl-D / Ctrl-C выход")
|
||||
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import pw.binom.agentik.tui.AppState
|
||||
import pw.binom.agentik.tui.TuiMessage
|
||||
|
||||
/**
|
||||
* Прокручиваемый (через клавиатуру) лог диалога.
|
||||
* Каждое сообщение рендерится отдельной строкой с префиксом (см. [renderMessage]).
|
||||
* При пустом списке показывается подсказка.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HistoryPanel(state: AppState) {
|
||||
val messages by state.messages.collectAsState()
|
||||
val rendered = if (messages.isEmpty()) {
|
||||
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||
} else {
|
||||
messages.joinToString("") { renderMessage(it) }
|
||||
}
|
||||
Text(value = rendered)
|
||||
}
|
||||
|
||||
/** Превращает [TuiMessage] в одну строку с префиксом. Потоковые чанки получают курсор `▍`. */
|
||||
internal fun renderMessage(m: TuiMessage): String = when (m) {
|
||||
is TuiMessage.System -> " ── ${m.text}\n"
|
||||
is TuiMessage.User -> " > ${m.text}\n"
|
||||
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
||||
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
||||
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
||||
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.layout.KeyEvent
|
||||
import com.jakewharton.mosaic.layout.drawBehind
|
||||
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import pw.binom.agentik.tui.AppState
|
||||
|
||||
/**
|
||||
* Нижняя строка ввода с курсором.
|
||||
* При активном стриме ассистента показывает ` ⋯`, иначе ` >`.
|
||||
*
|
||||
* Содержимое строки: `<prompt> <before>|<cursorChar>|<after>` —
|
||||
* `cursorChar` — это символ, на котором стоит курсор (или пробел в конце).
|
||||
*
|
||||
* Клавиши обрабатываются через [handleInputKey] внутри `onPreviewKeyEvent`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InputLine(state: AppState) {
|
||||
val text by state.input.collectAsState()
|
||||
val cursor by state.cursor.collectAsState()
|
||||
val streaming by state.streaming.collectAsState()
|
||||
val cursorPos = cursor.coerceIn(0, text.length)
|
||||
val before = text.substring(0, cursorPos)
|
||||
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
||||
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
||||
val after = text.substring(afterStart.coerceAtMost(text.length))
|
||||
val prompt = if (streaming) " ⋯" else " >"
|
||||
|
||||
Text(
|
||||
value = "$prompt $before|$cursorChar|${after}",
|
||||
modifier = Modifier
|
||||
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
||||
.drawBehind {
|
||||
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
||||
state.input.let { /* touch */ }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка клавиш в [InputLine]. `true` = событие поглощено.
|
||||
*
|
||||
* Не перехватывает клавиши с `alt`/`ctrl` — они идут дальше
|
||||
* на корневой обработчик ([pw.binom.agentik.tui.App]).
|
||||
*/
|
||||
internal fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
||||
if (ev.alt || ev.ctrl) return false
|
||||
return when (ev.key) {
|
||||
"Enter" -> state.submitInput() != null
|
||||
"Backspace" -> { state.inputBackspace(); true }
|
||||
"Delete" -> { state.inputDelete(); true }
|
||||
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
||||
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
||||
"Home" -> { state.inputCursorHome(); true }
|
||||
"End" -> { state.inputCursorEnd(); true }
|
||||
else -> {
|
||||
val s = ev.key
|
||||
if (s.length == 1) { state.inputInsert(s); true }
|
||||
else false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Минимальный fake [Agent] для тестов [TuiBackend]: считает, сколько раз
|
||||
* вызвали [createConversation], и отдаёт заранее сконструированные
|
||||
* [FakeConversation].
|
||||
*/
|
||||
internal class FakeAgent(
|
||||
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
|
||||
) : Agent {
|
||||
override val id: String = "fake"
|
||||
var createCount: Int = 0
|
||||
private set
|
||||
val conversations = mutableListOf<FakeConversation>()
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation {
|
||||
createCount++
|
||||
val c = conversationFactory()
|
||||
conversations += c
|
||||
return c
|
||||
}
|
||||
|
||||
override suspend fun getConversation(id: String): Conversation? =
|
||||
conversations.firstOrNull { it.id == id }
|
||||
|
||||
override suspend fun deleteConversation(id: String): Boolean =
|
||||
conversations.removeAll { it.id == id }
|
||||
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
||||
conversations.toList()
|
||||
|
||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
||||
}
|
||||
|
||||
/**
|
||||
* [Conversation], запоминающий все вызовы [send] и эмитящий управляемые
|
||||
* [Event] через общий [MutableSharedFlow]. Используется в тестах
|
||||
* [TuiBackend] для проверки маршрутизации событий в UI.
|
||||
*/
|
||||
internal class FakeConversation(
|
||||
override val id: String = "fake-conv",
|
||||
override val title: String? = null,
|
||||
) : Conversation {
|
||||
override val isSupportImageInput: Boolean = false
|
||||
override val isSupportImageOutput: Boolean = false
|
||||
override val isTemporal: Boolean = false
|
||||
override val updatedAt: Instant = Instant.DISTANT_PAST
|
||||
|
||||
val sent = mutableListOf<List<Content>>()
|
||||
val sentContexts = mutableListOf<MessageContext?>()
|
||||
var closed: Boolean = false
|
||||
private set
|
||||
var interrupted: Boolean = false
|
||||
private set
|
||||
|
||||
private val eventsFlow = MutableSharedFlow<Event>(extraBufferCapacity = 64)
|
||||
fun emit(e: Event) { eventsFlow.tryEmit(e) }
|
||||
|
||||
override suspend fun rename(title: String) = Unit
|
||||
|
||||
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||
sent += content
|
||||
sentContexts += context
|
||||
}
|
||||
|
||||
override suspend fun interrupt() { interrupted = true }
|
||||
|
||||
override fun events(after: Instant): Flow<Event> = eventsFlow
|
||||
|
||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
|
||||
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Тесты [TuiBackend]. Используем [runTest.backgroundScope] (а не TestScope)
|
||||
* для передачи в `start` — фоновые подписки должны жить параллельно с
|
||||
* телом теста и автоматически отменяться по его завершении. Иначе
|
||||
* бесконечный collect на `agent.events()` завешивает runTest на 60s
|
||||
* `UncompletedCoroutinesError`.
|
||||
*
|
||||
* [runCurrent] нужен после каждого `onUserMessage` и каждого `emit`,
|
||||
* потому что `backgroundScope` использует свой диспетчер, который не
|
||||
* продвигается через `advanceUntilIdle` — `runCurrent` прогоняет ровно
|
||||
* те задачи, что готовы к запуску сейчас.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TuiBackendTest {
|
||||
|
||||
private fun fixtureConfig(server: String = "http://localhost:8080/agentik") =
|
||||
TuiConfig(server = server, id = "cli-tui:tester", historyEnabled = true)
|
||||
|
||||
@Test
|
||||
fun `start posts connected system message`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent()
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(
|
||||
sysMsgs.any { it.text.contains(cfg.server) },
|
||||
"ожидалось системное 'подключено к ${cfg.server}', было: ${sysMsgs.map { it.text }}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first onUserMessage auto-creates conversation with temp=false`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent()
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("привет")
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, agent.createCount, "должен быть один createConversation")
|
||||
assertEquals(listOf("привет"), agent.conversations.first().sent.flattenText())
|
||||
// temp=false — обычный (не временный) диалог: персистится на сервере
|
||||
assertFalse(agent.conversations.first().isTemporal, "диалог не должен быть временным")
|
||||
// state знает id и title нового диалога
|
||||
assertEquals("fake-conv", state.currentConversationId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second onUserMessage reuses same conversation`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent()
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("раз")
|
||||
runCurrent()
|
||||
backend.onUserMessage("два")
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, agent.createCount, "новый диалог создавать не должны — переиспользуем старый")
|
||||
assertEquals(2, agent.conversations.first().sent.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AppendText appends to current assistant streaming chunk`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.AppendText(now, "Привет"))
|
||||
conv.emit(Event.AppendText(now, ", мир"))
|
||||
runCurrent()
|
||||
|
||||
val assistantMsgs = state.messages.value.filterIsInstance<TuiMessage.AssistantStreaming>()
|
||||
assertEquals(1, assistantMsgs.size, "должен быть один streaming-чанк, не два")
|
||||
assertEquals("Привет, мир", assistantMsgs.single().text)
|
||||
assertTrue(state.streaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `End event finalizes assistant and stops streaming`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.AppendText(now, "ответ"))
|
||||
conv.emit(Event.End(now))
|
||||
runCurrent()
|
||||
|
||||
val last = state.messages.value.last()
|
||||
assertTrue(last is TuiMessage.Assistant, "после End последнее сообщение должно стать финальным Assistant, было: ${last::class.simpleName}")
|
||||
assertEquals("ответ", (last as TuiMessage.Assistant).text)
|
||||
assertFalse(state.streaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Interrupted event clears streaming and posts system message`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.AppendText(now, "часть ответа"))
|
||||
conv.emit(Event.Interrupted(now))
|
||||
runCurrent()
|
||||
|
||||
assertFalse(state.streaming.value)
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(
|
||||
sysMsgs.any { it.text.contains("прервано") },
|
||||
"ожидалось 'прервано' в системных сообщениях, было: ${sysMsgs.map { it.text }}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolCall and ToolResult events become visible tool messages`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
||||
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
|
||||
runCurrent()
|
||||
|
||||
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||
val resultMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolResult>()
|
||||
assertEquals(1, toolMsgs.size)
|
||||
assertEquals("echo", toolMsgs.single().toolName)
|
||||
assertEquals("""{"x":1}""", toolMsgs.single().args)
|
||||
assertEquals(1, resultMsgs.size)
|
||||
assertEquals("ok", resultMsgs.single().result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error event posts system message and clears streaming`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.Error(date = now, message = "boom"))
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(sysMsgs.any { it.text.contains("boom") }, "должно быть 'ошибка: boom'")
|
||||
assertFalse(state.streaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onUserMessage does not swallow exceptions - state stays consistent`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(
|
||||
sysMsgs.any { it.text.contains("ошибка отправки") || it.text.contains("server kaboom") },
|
||||
"должна быть системная ошибка, было: ${sysMsgs.map { it.text }}",
|
||||
)
|
||||
assertFalse(state.streaming.value, "стриминг должен быть выключен в catch-ветке")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StartReasoning posts thinking system message`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartReasoning(now))
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(sysMsgs.any { it.text.contains("думаю") })
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<List<Content>>.flattenText(): List<String> =
|
||||
map { cs -> cs.filterIsInstance<Content.Text>().joinToString("") { it.body } }
|
||||
@@ -0,0 +1,12 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
* Платформенные actual'ы для JVM. Используется `:client` поверх Ktor CIO.
|
||||
*/
|
||||
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||
|
||||
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
|
||||
AgentikAgent(id = id, baseUrl = baseUrl)
|
||||
@@ -0,0 +1,13 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
* Заглушка для native-целей: TUI на нативе пока не работает — нужно подключить
|
||||
* ktor-client-* движки и termios. Нативный бинарь собирается, но main() падает
|
||||
* с понятной ошибкой.
|
||||
*/
|
||||
internal actual fun platformEnv(key: String): String? = null
|
||||
|
||||
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
|
||||
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
|
||||
+134
-1
@@ -2,7 +2,140 @@ plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
// maven-publish — стандартный плагин Gradle, объявлен apply'ем в subprojects ниже.
|
||||
}
|
||||
|
||||
group = "pw.binom.agentik"
|
||||
version = "0.1.0"
|
||||
|
||||
// projectVersion определяется ниже как val, чтобы subprojects могли его
|
||||
// прочитать через rootProject.extra["projectVersion"].
|
||||
|
||||
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
|
||||
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
|
||||
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
|
||||
// gradle.properties). Без версии maven-publish падает с
|
||||
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
|
||||
// это известный gotcha: subprojects читают rootProject.version ДО того, как
|
||||
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
|
||||
// eagerly, и subprojects получают готовую строку.
|
||||
val projectVersion: String = providers.gradleProperty("version")
|
||||
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
|
||||
.getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
|
||||
version = projectVersion
|
||||
extra["projectVersion"] = projectVersion
|
||||
|
||||
// Home Nexus URL/creds — передаются через -Pbinom.repo.* из CI/CD workflow
|
||||
// (.gitea/workflows/release.yml). Локально для дебага:
|
||||
// ./gradlew publish -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
|
||||
// Без -P URL падает на дефолтный placeholder (заглушка для локальной разработки).
|
||||
val binomRepoUrl = (findProperty("binom.repo.url") as String? ?: "http://nexus.xx/repository/caffeine/").toString()
|
||||
val binomRepoUser = (findProperty("binom.repo.user") as String? ?: "").toString()
|
||||
val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").toString()
|
||||
|
||||
// Per-module POM description. Один источник истины — карта ниже,
|
||||
// лишнее в settings.gradle.kts держим в комментарии-зеркале.
|
||||
// При добавлении нового модуля — добавь строку сюда + README.md в его корень.
|
||||
// Кладём в rootProject.extra ДО apply KMP-плагина в subprojects (beforeEvaluate
|
||||
// срабатывает позже, чем apply плагина, поэтому просто положить extra в
|
||||
// beforeEvaluate — поздно).
|
||||
val moduleDescriptions: Map<String, String> = mapOf(
|
||||
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
|
||||
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
|
||||
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
|
||||
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
|
||||
"memory-api" to "agentik :memory-api — интерфейсы долговременной памяти (MemoryStore, MemoryCategory, MemoryNote).",
|
||||
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
||||
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
||||
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
||||
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
|
||||
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
||||
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
||||
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
||||
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
|
||||
)
|
||||
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
|
||||
|
||||
subprojects {
|
||||
group = rootProject.group
|
||||
|
||||
// KMP-плагин читает project.version на ранней стадии evaluation — ДО того
|
||||
// как сработает внешний subprojects-блок. Если version ещё "unspecified",
|
||||
// publication 'kotlinMultiplatform' создаётся с пустой version, и тогда
|
||||
// maven-publish падает с 'InvalidMavenPublicationException: version cannot
|
||||
// be empty'. Поэтому:
|
||||
// 1) eagerly переопределяем version в rootProject.extra (см. выше)
|
||||
// 2) на КАЖДЫЙ subproject вешаем beforeEvaluate, который выставляет
|
||||
// version до того, как KMP-плагин начнёт создавать publications.
|
||||
|
||||
// per-module POM description берётся из rootProject.extra["moduleDescriptions"]
|
||||
// (см. корень build.gradle.kts); добавлять новый модуль — туда + README.md.
|
||||
|
||||
// beforeEvaluate срабатывает ДО apply плагинов в build.gradle.kts модуля, так
|
||||
// что version/description уже валидны, когда KMP-плагин начинает создавать
|
||||
// publications.
|
||||
beforeEvaluate {
|
||||
description = (rootProject.extra["moduleDescriptions"] as Map<String, String>)[project.name]
|
||||
?: "agentik module: ${project.name}"
|
||||
version = rootProject.extra["projectVersion"] as String
|
||||
}
|
||||
|
||||
apply(plugin = "maven-publish")
|
||||
|
||||
extensions.configure<PublishingExtension>("publishing") {
|
||||
repositories {
|
||||
maven {
|
||||
name = "caffeine"
|
||||
url = uri(binomRepoUrl)
|
||||
isAllowInsecureProtocol = true
|
||||
credentials {
|
||||
username = binomRepoUser
|
||||
password = binomRepoPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-subproject POM-метаданные (name, scm, licenses, developers).
|
||||
// Также явно выставляем version/group для каждой публикации. В KMP-модулях
|
||||
// (особенно JVM-only с одним jvm() target) kotlin-multiplatform plugin
|
||||
// создаёт publication 'kotlinMultiplatform' на ранней стадии evaluation,
|
||||
// когда project.version ещё 'unspecified'. Простое присваивание
|
||||
// subprojects { version = ... } НЕ перезаписывает уже зафиксированную
|
||||
// version в publication → InvalidMavenPublicationException в CI.
|
||||
// Явная установка version здесь гарантирует, что публикация всегда
|
||||
// использует актуальное значение из rootProject.extra.
|
||||
publications.withType<MavenPublication>().configureEach {
|
||||
groupId = rootProject.group.toString()
|
||||
artifactId = project.name
|
||||
version = rootProject.extra["projectVersion"] as String
|
||||
|
||||
pom {
|
||||
name = project.name
|
||||
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
|
||||
?: "agentik module: ${project.name}"
|
||||
url = "https://git.binom.pw/subochev/agentik"
|
||||
|
||||
licenses {
|
||||
license {
|
||||
name = "Apache-2.0"
|
||||
url = "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
|
||||
developers {
|
||||
developer {
|
||||
id = "subochev"
|
||||
name = "Subochev Alexey"
|
||||
url = "https://git.binom.pw/subochev"
|
||||
}
|
||||
}
|
||||
|
||||
scm {
|
||||
connection = "scm:git:https://git.binom.pw/subochev/agentik.git"
|
||||
developerConnection = "scm:git:ssh://git@git.binom.pw/subochev/agentik.git"
|
||||
url = "https://git.binom.pw/subochev/agentik"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
|
||||
|
||||
## Что это
|
||||
|
||||
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
|
||||
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
|
||||
интерфейсы `:proto`:
|
||||
|
||||
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
|
||||
- `AgentClient` — список и lifecycle диалогов.
|
||||
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
||||
`getMessages()`, `rename()`, `close()`.
|
||||
- Внутренний парсер SSE → `Flow<Event>`.
|
||||
|
||||
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
|
||||
с теми же типами, что и сервер. Один и тот же клиент работает на
|
||||
JVM, iOS, macOS, Linux, Windows.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:agentik-cli` — REPL.
|
||||
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
||||
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:client:0.1.0")
|
||||
}
|
||||
}
|
||||
|
||||
// ваш код:
|
||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
||||
val conv = agent.createConversation(title = "test")
|
||||
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
||||
when (event) {
|
||||
is Event.AppendText -> print(event.body)
|
||||
is Event.End -> println("\n--- end ---")
|
||||
is Event.Error -> error("agent error: ${event.message}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
||||
|
||||
Поддерживает все KMP-таргеты, что и `:proto`.
|
||||
|
||||
## Примеры API
|
||||
|
||||
```kotlin
|
||||
// список диалогов
|
||||
agent.getConversations().collect { println(it.id to it.title) }
|
||||
|
||||
// live-подписка на события отдельного диалога
|
||||
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
||||
|
||||
// прерывание текущего хода
|
||||
conversation.interrupt()
|
||||
|
||||
// история
|
||||
conversation.getMessages(offset = 0).collect { msg ->
|
||||
when (msg) {
|
||||
is Message.UserMessage -> println("user: ${msg.content}")
|
||||
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :client:jvmTest
|
||||
```
|
||||
|
||||
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
||||
401/404.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого LLM-кода. Это просто клиент.
|
||||
- Никакого persistent state. История хранится у сервера, клиент её
|
||||
запрашивает через `getMessages` или подписывается через `events`.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
||||
но клиент совместим с любым сервером, который держит wire-контракт
|
||||
`:server`.
|
||||
|
||||
## Известное ограничение
|
||||
|
||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
||||
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||
+43
-17
@@ -1,24 +1,50 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
jvmToolchain(21)
|
||||
|
||||
// Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
|
||||
// :client не имеет смысла на iOS, а :agentik-cli использует :client и тоже
|
||||
// без iOS. См. agentik-cli/build.gradle.kts.
|
||||
jvm()
|
||||
listOf(
|
||||
macosX64(),
|
||||
macosArm64(),
|
||||
linuxX64(),
|
||||
linuxArm64(),
|
||||
mingwX64(),
|
||||
)
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":proto"))
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.test.host)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.server.cio)
|
||||
implementation(libs.ktor.server.sse)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation("junit:junit:4.13.2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proto"))
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// :client — это библиотека, не executable. Native-бинари объявляются
|
||||
// в :agentik-cli (он зависит от :client и реально предоставляет main).
|
||||
}
|
||||
|
||||
+10
-7
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
@@ -66,13 +67,15 @@ internal class AgentClient(
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
||||
val response = httpClient.get("$agentUrl/events?after=$after")
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
||||
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-11
@@ -1,9 +1,6 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
@@ -24,8 +21,8 @@ import pw.binom.agentik.proto.Agent
|
||||
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
||||
* конфига).
|
||||
*
|
||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
|
||||
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
|
||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
|
||||
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
|
||||
*/
|
||||
fun AgentikAgent(
|
||||
id: String,
|
||||
@@ -34,10 +31,16 @@ fun AgentikAgent(
|
||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
||||
|
||||
/**
|
||||
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
|
||||
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
|
||||
* (см. [readSse]) живёт в общем коде и плагина не требует.
|
||||
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
|
||||
* живёт в общем коде и плагина `SSEClientContent` не требует.
|
||||
*
|
||||
* **Платформы:**
|
||||
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
|
||||
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
|
||||
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
|
||||
* `ConversationClient.events`/`AgentClient.events` уже ставят
|
||||
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
|
||||
*
|
||||
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
|
||||
* Реализация — в [HttpClientFactory.kt].
|
||||
*/
|
||||
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
}
|
||||
+23
-9
@@ -6,6 +6,7 @@ import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.ContentType
|
||||
@@ -13,10 +14,12 @@ import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -58,10 +61,10 @@ internal class ConversationClient(
|
||||
snapshot = updated
|
||||
}
|
||||
|
||||
override suspend fun send(content: List<Content>) {
|
||||
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||
httpClient.post("$convUrl/messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(content)
|
||||
setBody(SendPayload(content, context))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +73,18 @@ internal class ConversationClient(
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<Event> = flow {
|
||||
val response = httpClient.get("$convUrl/events?after=$after")
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
||||
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
|
||||
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
|
||||
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
|
||||
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,3 +100,9 @@ internal class ConversationClient(
|
||||
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class SendPayload(
|
||||
val content: List<Content>,
|
||||
val context: MessageContext? = null,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
|
||||
/**
|
||||
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
|
||||
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
|
||||
*
|
||||
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
|
||||
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
|
||||
*/
|
||||
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
engine { requestTimeout = 0 }
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
}
|
||||
+4
-1
@@ -15,8 +15,11 @@ import kotlin.time.Instant
|
||||
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
||||
*/
|
||||
internal object InstantSerializer : KSerializer<Instant> {
|
||||
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
|
||||
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
|
||||
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
|
||||
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Instant) =
|
||||
encoder.encodeString(value.toString())
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||
import io.ktor.client.plugins.HttpTimeoutCapability
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
|
||||
/**
|
||||
* Отключает request/connect/socket-таймауты для конкретного запроса через
|
||||
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
|
||||
*
|
||||
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
|
||||
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||
*
|
||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||
* установленном capability мутирует его поля через `?:`, так что шаренный
|
||||
* инстанс мог бы утечь между запросами.
|
||||
*/
|
||||
internal fun HttpRequestBuilder.noSseReadTimeout() {
|
||||
setCapability(
|
||||
HttpTimeoutCapability,
|
||||
HttpTimeoutConfig(
|
||||
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpRequestTimeoutException
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondBytesWriter
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.utils.io.writeStringUtf8
|
||||
import io.ktor.utils.io.readUTF8Line
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.net.ServerSocket
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
|
||||
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
|
||||
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
|
||||
* перед каждым read-стримом.
|
||||
*
|
||||
* Тест запускает встроенный Ktor CIO-сервер на свободном порту. Сервер шлёт
|
||||
* "hello", ждёт 20 с (дольше дефолтного requestTimeout = 15 с), затем шлёт
|
||||
* "done". Без capability клиент отвалился бы на ~15 с; с capability — второе
|
||||
* сообщение доходит.
|
||||
*
|
||||
* Читаем строки пока не найдём "data: done" или пока не сработает
|
||||
* [withTimeout] (18 с — запас над server delay 20 с).
|
||||
*/
|
||||
class SseTimeoutTest {
|
||||
|
||||
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
|
||||
|
||||
@Test
|
||||
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
|
||||
val port = freePort()
|
||||
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||
routing {
|
||||
get("/sse") {
|
||||
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||
writeStringUtf8("data: hello\n\n")
|
||||
flush()
|
||||
// 17 с — чуть больше дефолтного CIO requestTimeout = 15 с.
|
||||
// Если capability сломана, клиент упадёт на 15 с и не получит "done".
|
||||
delay(17_000)
|
||||
writeStringUtf8("data: done\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val received = mutableListOf<String>()
|
||||
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||
header("Accept", "text/event-stream")
|
||||
noSseReadTimeout()
|
||||
}.execute { resp ->
|
||||
val ch = resp.bodyAsChannel()
|
||||
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
|
||||
// Если capability сломана, клиент упадёт на 15 с и мы словим исключение.
|
||||
val deadline = 19_000L
|
||||
val start = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - start < deadline) {
|
||||
val line = withTimeout<String?>(deadline) { ch.readUTF8Line() } ?: break
|
||||
if (line.startsWith("data: ")) {
|
||||
received.add(line)
|
||||
}
|
||||
if (line == "data: done") break
|
||||
}
|
||||
}
|
||||
assertTrue(received.contains("data: hello"), "должно получить hello: $received")
|
||||
assertTrue(
|
||||
received.contains("data: done"),
|
||||
"должно получить done (SSE read не должен падать на 15 с): $received",
|
||||
)
|
||||
assertFalse(
|
||||
received.any { it == "<timeout>" },
|
||||
"SSE read упал в timeout (capability не сработал): $received",
|
||||
)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
|
||||
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
|
||||
* Сервер держит stream 17 с; если клиент не выставил capability —
|
||||
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
|
||||
* дожидаясь "done".
|
||||
*/
|
||||
@Test
|
||||
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
|
||||
val port = freePort()
|
||||
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||
routing {
|
||||
get("/sse") {
|
||||
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||
writeStringUtf8("data: hello\n\n")
|
||||
flush()
|
||||
delay(17_000)
|
||||
writeStringUtf8("data: done\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val start = System.currentTimeMillis()
|
||||
try {
|
||||
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||
header("Accept", "text/event-stream")
|
||||
// НАМЕРЕННО без noSseReadTimeout.
|
||||
}.execute { resp ->
|
||||
val ch = resp.bodyAsChannel()
|
||||
// Читаем строки, пока не придёт "data: done" — без capability
|
||||
// клиент упадёт на ~15 с до того, как сервер пошлёт done.
|
||||
while (true) {
|
||||
val line = ch.readUTF8Line() ?: break
|
||||
if (line == "data: done") break
|
||||
}
|
||||
}
|
||||
fail("без capability клиент должен словить HttpRequestTimeoutException")
|
||||
} catch (e: HttpRequestTimeoutException) {
|
||||
val elapsed = System.currentTimeMillis() - start
|
||||
assertTrue(
|
||||
elapsed in 14_000..17_000,
|
||||
"timeout должен сработать в районе 15 с (default), elapsed=$elapsed",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
# Memory — дизайн (draft)
|
||||
|
||||
> Обсуждение долговременной памяти агента. Начат 2026-09-13.
|
||||
> Цель — выбрать модель хранения и поиска **до** написания кода.
|
||||
>
|
||||
> **Update 2026-09-14:** пивот хранения. Поднимаем не один monolithic
|
||||
> backend, а **абстракцию** (`MemoryStore`/`MemoryPrefetcher`/`MemoryReviewer`/
|
||||
> `MemoryTools` в `:memory-api`) и подменяемые реализации. v1 идёт на
|
||||
> Hermes-style **§-файлах** в `~/.agentik/memory/{USER,WORLD,PREFERENCES}.md`
|
||||
> (модуль `:memory-md`). SQLite+vector sidecar из §3 ниже переезжает в
|
||||
> следующий бэкенд (`:memory-vector`, фаза 2+) — обоснование там же,
|
||||
> отличий в API не будет. §3.1 («почему не файлы») остаётся аргументом
|
||||
> *против единого источника истины вне основной БД*, но под абстракцией
|
||||
> это уже не та проблема: факты в MD живут отдельно, а всё остальное
|
||||
> state диалогов по-прежнему в `agentik.db`.
|
||||
|
||||
## 1. Требования
|
||||
|
||||
Что память должна делать в agentik:
|
||||
|
||||
1. **Хранить факты** между сессиями: о пользователе (USER), о мире/проектах (WORLD), о предпочтениях (PREFERENCE).
|
||||
2. **Быстро находить релевантное** перед каждым ходом (prefetch, top-K).
|
||||
3. **Быть перезаписываемой** — пользователь и сам агент могут удалять/править/архивировать.
|
||||
4. **Переживать рестарты** — данные не теряются.
|
||||
5. **Работать на native таргетах** (`:server` уже KMP, `:standalone` linuxX64 в плане).
|
||||
6. **Single-binary** — никаких внешних сервисов типа Qdrant.
|
||||
|
||||
Чего **не** обязательно в v1:
|
||||
- миллионы заметок;
|
||||
- мультипользовательские tenant'ы;
|
||||
- sub-ms ANN на >100K векторов.
|
||||
|
||||
## 2. Варианты хранения
|
||||
|
||||
### A. Текстовые файлы (Hermes-style)
|
||||
- `~/.hermes/memories/MEMORY.md` и `USER.md`, разделитель записей `§`.
|
||||
- Поиск: substring / FTS5 / LLM-ранжирование.
|
||||
- **+** человекочитаемо, легко бэкапить (`cp`), редактировать руками.
|
||||
- **−** не семантический: «как мы деплоим» не найдёт «systemctl + k3s».
|
||||
- **−** параллельно с SQLite (у нас всё остальное в `agentik.db`) — два места правды.
|
||||
|
||||
### B. SQLite + FTS5
|
||||
- Всё в `agentik.db`: `memory_note` + `memory_note_fts` (виртуальная FTS5-таблица).
|
||||
- Поиск: FTS5 BM25 + trigram (для русского).
|
||||
- **+** один процесс, знакомый API, никаких новых зависимостей.
|
||||
- **−** keyword-only: «k8s» не матчится с «kubernetes», «Go» не находится в «статически типизированном языке с горутинами».
|
||||
|
||||
### C. SQLite (canonical) + векторный sidecar
|
||||
- Канонический store: `memory_note(id, category, content, created_at, last_used_at, use_count, conversation_id?, source, embedding_model, embedding_dim)` — обычные столбцы.
|
||||
- Векторный индекс: либо BLOB-столбец с packed Float32Array + brute-force cosine, либо отдельная embedded-БД (sqlite-vss, LanceDB).
|
||||
- **+** семантический поиск «по смыслу»; canonical store остаётся SQL-инспектируемым (можно `grep`, `sqlite3 agentik.db "SELECT …"`).
|
||||
- **+** гибридный ranking: similarity × recency × use_count.
|
||||
- **−** зависимость на embedding-модель.
|
||||
|
||||
### D. Чисто векторная БД (Qdrant / Milvus / Weaviate / Chroma)
|
||||
- **+** production-grade ANN.
|
||||
- **−** отдельный процесс, **ломает single-binary философию agentik**.
|
||||
|
||||
## 3. Рекомендация — гибрид **C**
|
||||
|
||||
**Канонический store — SQLite (как у нас всё остальное). Векторный индекс — sidecar.**
|
||||
|
||||
### 3.1 Почему не A (только файлы)
|
||||
|
||||
- В agentik **всё** состояние уже в SQLite: разговоры, working memory, summary, ошибки. Раздваивать на файлы = доп. синхронизация при каждом save/delete + новая категория бэкапов.
|
||||
- Файлы не масштабируются на >100 заметок без индекса: `grep -F` это O(N) по байтам.
|
||||
- Семантический поиск всё равно хочется — придётся добавлять векторы позже, тогда MD превращается в sidecar с теми же проблемами синхронизации, но без преимуществ.
|
||||
|
||||
### 3.2 Почему не B (только FTS5)
|
||||
|
||||
- Keyword-поиск быстро упирается в перефразирование: пользователь пишет «на чём билдим?», заметка говорит «building with Gradle 9.4.1» — без морфологии и/или эмбеддингов не находится.
|
||||
- Мультиязычность (русские заметки, английские запросы) — FTS5 с trigram работает, но семантика всё равно точнее.
|
||||
- Цена эмбеддингов на нашем масштабе ($0.004 на 1000 заметок единоразово + копейки на save) практически нулевая.
|
||||
|
||||
### 3.3 Почему не sqlite-vss / LanceDB embedded
|
||||
|
||||
- **sqlite-vss** — расширение SQLite, надо пересобирать под каждый native таргет (linuxX64, iosArm64, iosSimulatorArm64, mingwX64). Блокирует наш linuxX64-чек (NATIVE-COMPATIBILITY.md).
|
||||
- **LanceDB Java SDK** — есть (Apache 2.0), но JVM-only (JNI к нативной `.so`); на ios/iosSimulator без отдельного билда не работает.
|
||||
- На нашем масштабе (<10K заметок на агента) **brute-force cosine по BLOB-столбцу** — правильный порядок сложности:
|
||||
- 1 эмбеддинг = 1536 floats × 4 байта ≈ 6 KB (OpenAI small) или 384 × 4 ≈ 1.5 KB (MiniLM).
|
||||
- 10K × 6 KB = 60 MB в RAM. Дешево.
|
||||
- Поиск: 10K cosine-similarity = ~0.5 ms на JVM, <0.1 ms нативно. Не нужен HNSW.
|
||||
|
||||
Если когда-нибудь выйдем за 50K заметок — переедем на sqlite-vss или LanceDB без поломки API: `MemoryStore.search(query, k)` остаётся прежним.
|
||||
|
||||
### 3.4 Схема канонического store (предложение)
|
||||
|
||||
```sql
|
||||
CREATE TABLE memory_note (
|
||||
id TEXT PRIMARY KEY, -- "mem-<uuid>"
|
||||
category TEXT NOT NULL, -- 'user' | 'world' | 'preference'
|
||||
content TEXT NOT NULL, -- полный текст заметки
|
||||
conversation_id TEXT, -- NULL = global, иначе привязана к диалогу
|
||||
source TEXT NOT NULL, -- 'agent_save' | 'user_explicit' | 'auto_review'
|
||||
created_at INTEGER NOT NULL, -- epoch ms
|
||||
last_used_at INTEGER NOT NULL, -- когда последний раз матчилась в prefetch
|
||||
use_count INTEGER NOT NULL DEFAULT 0, -- сколько раз выдавалась в prefetch
|
||||
embedding_model TEXT NOT NULL, -- 'openai/text-embedding-3-small' (для миграций)
|
||||
embedding_dim INTEGER NOT NULL,
|
||||
embedding BLOB NOT NULL -- packed Float32Array, little-endian
|
||||
);
|
||||
|
||||
CREATE INDEX memory_note_category_idx ON memory_note(category);
|
||||
CREATE INDEX memory_note_last_used_idx ON memory_note(last_used_at DESC);
|
||||
CREATE INDEX memory_note_conversation_idx ON memory_note(conversation_id);
|
||||
```
|
||||
|
||||
Размер: на 10K заметок ≈ 60 MB (OpenAI small) или 15 MB (MiniLM). Дешевле, чем `agentik.db`-кеш.
|
||||
|
||||
## 4. Embedding: где считать
|
||||
|
||||
| Вариант | + | − |
|
||||
|---|---|---|
|
||||
| **Удалённо через litellm** (`text-embedding-3-small`, 1536-dim, $0.02/1M tokens) | 0 локальных деплов, высокое качество, мультиязычный | +1 HTTP на save/query; нужен ключ OpenAI |
|
||||
| **Локально через ONNX Runtime** (`all-MiniLM-L6-v2`, 384-dim, $0) | оффлайн, native-совместимо, без задержек | +25 MB к бинарю; хуже качество; инициализация ~500 ms |
|
||||
| **Через GOOGLE backend** (Gemini embedding) | уже работающее API | привязывает embedding к backend; GOOGLE может не иметь OpenAI embedding-API |
|
||||
| **Гибрид** (по умолчанию OpenAI, fallback на локальный если ключа нет) | resilience | +сложность |
|
||||
|
||||
**Рекомендация:** на v1 — **отдельный embedding-конфиг**, дефолт OpenAI через litellm, фоллбэк на локальный MiniLM через ONNX (если выйдет 0.3+ java SDK и мы соберём native-билды под все таргеты — отложим в v2).
|
||||
|
||||
### Стоимость на реальном использовании
|
||||
|
||||
- text-embedding-3-small, 1000 заметок × 200 токенов = 200K токенов = **$0.004** единоразово.
|
||||
- `memory_save` ≈ $0.00001 (200 токенов на индексацию).
|
||||
- `prefetch` (1 query embedding) ≈ $0.000003.
|
||||
- 100 заметок/месяц, 50 ходов/день = <$0.01/месяц. Ничтожно.
|
||||
|
||||
## 5. Жизненный цикл заметки
|
||||
|
||||
```
|
||||
review-loop (post-turn)
|
||||
│
|
||||
▼
|
||||
memory_save(category, content) ──► write to memory_note
|
||||
│ │
|
||||
│ ├──► embedding = embed(content)
|
||||
│ ├──► use_count = 0
|
||||
│ └──► last_used_at = created_at
|
||||
│
|
||||
▼
|
||||
prefetch(user_message, topK=10) ──► vector top-K + recency rerank
|
||||
│ │
|
||||
│ ▼
|
||||
│ bump use_count, last_used_at
|
||||
│ │
|
||||
│ ▼
|
||||
│ inject into user message prefix:
|
||||
│ "[Контекст — то, что я помню]
|
||||
│ - факт 1
|
||||
│ - факт 2
|
||||
│ [/Контекст]"
|
||||
│
|
||||
▼
|
||||
(eventually)
|
||||
│
|
||||
manual memory_delete(id) ◄── пользовательская команда
|
||||
│
|
||||
curator: last_used_at < 90 days ago ──► status = 'archived' (не видна в prefetch)
|
||||
```
|
||||
|
||||
`status` в схеме нет — архивация = `use_count == 0 AND last_used_at < archive_threshold`. Чисто по таймстампам, без LLM. Curator (Фаза 4) делает это в фоне раз в сутки.
|
||||
|
||||
## 6. Review-loop (как у Hermes, адаптированный)
|
||||
|
||||
Не fork-agent. **Один-shot LLM-вызов** на `LiteLlm.sendStreamContents` с урезанным туловым whitelist'ом (`memory_save`, `memory_search`, `memory_list`, `skill_save`). Тот же backend, что у основного агента.
|
||||
|
||||
Триггер: **каждые N ходов** (default 10, настраивается `AGENTIK_MEMORY_NUDGE_INTERVAL`).
|
||||
Условие: только после **успешно** завершённого хода (не прерванного, не упавшего).
|
||||
|
||||
Промпт (рус/англ по языку разговора):
|
||||
|
||||
```
|
||||
Ты — фоновый аналитик. Посмотри на последний разговор и реши, есть ли
|
||||
что запомнить в долговременную память агента. Сохраняй только если:
|
||||
1. Пользователь рассказал о себе: persona, привычки, предпочтения.
|
||||
2. Пользователь рассказал о проекте/окружении: стек, инструменты, сроки.
|
||||
3. Пользователь выразил ожидания к тому, как агент должен работать.
|
||||
|
||||
Если ничего нет — просто ответь "nothing to save" и не вызывай тулы.
|
||||
Если есть — вызови memory_save(category, content) для каждого факта.
|
||||
```
|
||||
|
||||
`category`: одна из `user` / `world` / `preference`. Агент решает сам.
|
||||
|
||||
## 7. Открытые вопросы (нужны решения)
|
||||
|
||||
| # | Вопрос | Предложение |
|
||||
|---|---|---|
|
||||
| 1 | **Переносимость embeddings между моделями.** Поменяем модель → переиндексировать всё? | Хранить `embedding_model` в строке; на старте проверять, что у всех строк он одинаковый; иначе фоновый reindex. Дёшево ($0.004 на 1000 заметок). |
|
||||
| 2 | **Мультиязычность.** Русские заметки + английские запросы. | text-embedding-3-small мультиязычен (тестировал на рус+англ — ок); MiniLM — частично. На v1 OpenAI хватает. |
|
||||
| 3 | **TTL заметок.** Hermes без TTL. У нас возможны устаревшие факты. | Curator (Фаза 4): `use_count == 0 AND last_used_at < 90d` → архив. Без жёсткого удаления. |
|
||||
| 4 | **Персональные данные / секреты.** Можем сохранить API-ключ из разговора. | Guard rail в `memory_save`: regex-detect на токеноподобные паттерны (`sk-…`, `Bearer …`, JWT); агент не должен сохранять. Финальный review — пользователь. Шифрование at-rest — отдельная тема (см. общий security pass). |
|
||||
| 5 | **Когда НЕ писать.** Review должен решить «не сохранять». | Встроено в промпт выше. Если агент сомневается — не пишет. |
|
||||
| 6 | **Каталог и формат SKILL-аналога для памяти.** Hermes делает это как `USER.md` vs `MEMORY.md`. У нас одна таблица с `category`. Нужно ли разделение? | Одной таблицы достаточно (категория в строке). Преимущество: один запрос, одна транзакция. |
|
||||
| 7 | **Привязка к conversation_id.** Глобальная память vs per-conversation. | Колонка nullable. По умолчанию глобальная (`NULL`). Привязка — только когда факт явно про «этот диалог» (например, «в этом диалоге используем минимальный API»). |
|
||||
| 8 | **Prefetch size.** Сколько фактов впрыскивать в user message? | Default 10, настраивается `AGENTIK_MEMORY_PREFETCH_TOPK`. |
|
||||
| 9 | **Где считать query embedding — клиент или «агент»?** | На стороне агента (там же, где `LiteLlm`). Один HTTP-вызов на turn. |
|
||||
| 10 | **Что делать с дубликатами?** «Пользователь — DevOps» vs «Пользователь работает с k8s» — это две заметки или одна с тегами? | v1: две отдельные заметки. Curator (Фаза 4) объединяет похожие через LLM. |
|
||||
|
||||
## 8. Что фиксируем **до кода**
|
||||
|
||||
- [ ] **Канонический store**: SQLite-таблица `memory_note` в общей `agentik.db`.
|
||||
- [ ] **Поиск**: семантический (embedding) + recency/use_count rerank.
|
||||
- [ ] **Embedding backend**: `openai/text-embedding-3-small` через litellm; в v2 — локальный MiniLM через ONNX.
|
||||
- [ ] **Vector index**: brute-force cosine по BLOB-столбцу (v1) → sqlite-vss / LanceDB (v2 если нужно).
|
||||
- [ ] **Single-binary**: всё в нашем процессе, без внешних сервисов.
|
||||
- [ ] **Prefetch**: топ-K (default 10) заметок в user-message-префиксе.
|
||||
- [ ] **Review-loop**: один-shot LiteLlm-вызов с whitelist-тулсетом, каждые N ходов.
|
||||
- [ ] **Curator** (отложен в Фазу 4): архивация по `use_count` + `last_used_at`.
|
||||
|
||||
## 9. Что НЕ делаем в v1
|
||||
|
||||
- /learn и автоматическое создание скиллов (отдельная фаза).
|
||||
- Vector compression / quantization (нужно только при >100K заметок).
|
||||
- Multi-agent shared memory (один пользователь — один агент — один набор заметок).
|
||||
- Encryption at rest (общий security pass).
|
||||
- Embedding-кэш для текстов, которые уже были заэмбеджены (можно LRU в RAM).
|
||||
- Memory.conflict resolution (агент сохранил «k8s», потом «nomad» — это конфликт или эволюция? v1 не разрешает, v2 — curator).
|
||||
|
||||
## 10. Пофазный план
|
||||
|
||||
### Фаза 1 — Память (v2.1, ~600 строк кода + ~300 тестов)
|
||||
|
||||
**Цель:** агент запоминает между сессиями.
|
||||
|
||||
- `:memory` KMP-модуль: `MemoryNote`, `MemoryCategory` (`USER | WORLD | PREFERENCE`), `MemoryStore` (commonMain интерфейс) + SQLite jvm-impl.
|
||||
- Тулы: `MemoryReadTool` (поиск), `MemorySaveTool`, `MemoryListTool`, `MemoryDeleteTool`.
|
||||
- `MemoryPrefetcher.prefetch(query, topK): List<MemoryNote>` — embed query, brute-force cosine, rerank по recency × use_count.
|
||||
- `EmbeddingClient` — обёртка над litellm `/v1/embeddings` (один HTTP-вызов, retry, кэш in-RAM LRU на 256 текстов).
|
||||
- `ChatAgent.memory: MemoryStore` + `EmbeddingClient` параметры.
|
||||
- `ChatConversation.send()`: перед каждым `sendStreamContents` инжектит top-K заметок в префикс user-сообщения.
|
||||
- `ChatAgent.afterTurn()` hook: после успешного хода инкрементит `_turnsSinceReview`; если ≥ `memoryNudgeInterval` — запускает фоновую корутину `MemoryReviewAgent`.
|
||||
- `MemoryReviewAgent`: один-shot `LiteLlm.sendStreamContents` с review-промптом + whitelist-тулсетом (только `memory_*` + `skill_save`). Результат — 0+ записей в `memory_note`.
|
||||
- `SystemGuidance` константы: `MEMORY_GUIDANCE`, `SKILLS_GUIDANCE`, `TOOL_USE_ENFORCEMENT` (из Hermes `prompt_builder.py`).
|
||||
- Конфиг: `AGENTIK_MEMORY_BACKEND` (openai/local/none), `AGENTIK_MEMORY_NUDGE_INTERVAL` (10), `AGENTIK_MEMORY_PREFETCH_TOPK` (10), `AGENTIK_EMBEDDING_MODEL` (text-embedding-3-small).
|
||||
- **Тесты:** MemoryStore round-trip, prefetch ranking, review-loop пишет 0–N заметок на фейковом LLM, защита от секретов (regex), embedding-кэш работает, миграция схемы идемпотентна.
|
||||
|
||||
### Фаза 2 — Контекстная компрессия (v2.2, ~500 строк)
|
||||
|
||||
**Цель:** диалог переживает 50+ ходов без 400 от LLM.
|
||||
|
||||
- `TokenEstimator` — грубая оценка (chars / 4) по working memory.
|
||||
- `Compressor.shouldCompress()` — триггер: `tokens > threshold_percent * contextLimit` (default 75%). Anti-thrashing: ≤5 неудачных попыток подряд → пауза 10 минут.
|
||||
- `WorkingMemoryStore.compact(dropFromOrderIdx, summaryEntry: MessageRecord.Summary)` расширяется: drop + insert в одной транзакции. Уже сигнатура, нужно тело.
|
||||
- `ChatConversation.afterTurn()` (после review-loop): если `shouldCompress` — `head + summary + tail`:
|
||||
- head = system + первые 3 не-system записи;
|
||||
- middle = всё что не head/tail; уходит в aux-вызов;
|
||||
- tail = последние ~20K токенов (или 8 записей, что больше).
|
||||
- Aux summarizer prompt — портированный из Hermes `context_compressor.py` (структура с Historical Task Snapshot / Goal / Active State / Blocked / Resolved / Remaining Work).
|
||||
- Резюме пишется как `MessageRecord.Summary(text, createdAt)` в working memory, заменяет middle в `getOrCreateLiteConversation`.
|
||||
- **Тесты:** каждая граничная ситуация (head+1, всё в tail, пустой middle, ошибка aux-LLM → static fallback).
|
||||
|
||||
### Фаза 3 — Skill self-improvement (v2.3, ~400 строк)
|
||||
|
||||
**Цель:** агент сам создаёт/обновляет скиллы.
|
||||
|
||||
- `SkillSaveTool(action=create|update, name, description, body)` (LiteTool).
|
||||
- Расширить `SkillCatalog` в `:skills`: `lastUsedAt`, `useCount`, `archivedAt`. Сохранение в SQLite (`skill_usage` таблица) — нужно решить, отдельная БД или в `agentik.db`. Рекомендую в `agentik.db` (та же причина, что и для memory).
|
||||
- `SkillLoader.loadDirectory` теперь читает timestamp + useCount из SQLite при наличии, иначе — из filesystem mtime.
|
||||
- `MemoryReviewAgent` дополняется skill-частью (combined prompt) или запускается параллельно вторым вызовом.
|
||||
- Тулы в whitelist review: `memory_*` + `skill_save` + `skill_delete`.
|
||||
|
||||
### Фаза 4 — Curator (v2.4, ~300 строк)
|
||||
|
||||
**Цель:** фоновая уборка устаревших заметок и скиллов.
|
||||
|
||||
- Корутина `CuratorJob` запускается в `Main.kt`, тик раз в сутки (настраивается).
|
||||
- **Без LLM:** сканирует `memory_note` и `skills`. Если `last_used_at < 90d` и `use_count == 0` → `archivedAt = now()`. Не удалять.
|
||||
- **С LLM (опц., выкл по умолчанию):** consolidation pass — fork-agent (один-shot) ищет похожие заметки, объединяет в umbrella-заметку, архивирует исходные. Для скиллов — то же самое.
|
||||
- Через `:server` endpoint `GET /memory` / `GET /memory/archived` для пользовательского контроля.
|
||||
|
||||
### Фаза 5 — Умный prefetch (v2.5, отложено)
|
||||
|
||||
- Переход brute-force → sqlite-vss при >50K заметок.
|
||||
- Локальный embedding через ONNX Runtime (`all-MiniLM-L6-v2`) как fallback при отсутствии OpenAI-ключа.
|
||||
- Embedding-кэш на диске (LRU).
|
||||
- Vector quantization (int8) для экономии RAM.
|
||||
|
||||
## 11. Что меняется в коде
|
||||
|
||||
### Новые модули
|
||||
|
||||
```
|
||||
:memory (новый KMP модуль, commonMain + jvmMain)
|
||||
commonMain/.../MemoryNote.kt -- sealed: id, category, content, ...
|
||||
commonMain/.../MemoryCategory.kt -- USER | WORLD | PREFERENCE
|
||||
commonMain/.../MemoryStore.kt -- интерфейс (insert, list, search, delete)
|
||||
commonMain/.../EmbeddingClient.kt -- интерфейс (embed: String -> FloatArray)
|
||||
commonTest/.../MemoryStoreTest.kt
|
||||
jvmMain/.../sqlite/SqliteMemoryStore.kt -- INSERT/SELECT + brute-force cosine
|
||||
jvmMain/.../embedding/LitertlmEmbedding.kt -- HTTP-вызов /v1/embeddings через ktor-client
|
||||
jvmMain/.../embedding/InMemoryEmbedding.kt -- для тестов
|
||||
jvmTest/.../SqliteMemoryStoreTest.kt
|
||||
jvmTest/.../LitertlmEmbeddingTest.kt -- через WireMock или httptestserver
|
||||
```
|
||||
|
||||
### Изменения в `:standalone`
|
||||
|
||||
```
|
||||
agent/MemoryReadTool.kt / MemorySaveTool.kt / MemoryListTool.kt / MemoryDeleteTool.kt
|
||||
agent/MemoryReviewAgent.kt -- один-shot review, fire-and-forget coroutine
|
||||
agent/SkillSaveTool.kt -- в Фазе 3
|
||||
llm/SystemGuidance.kt -- MEMORY_GUIDANCE / SKILLS_GUIDANCE константы
|
||||
llm/CompressionPrompts.kt -- в Фазе 2
|
||||
persistence/WorkingMemoryStore.kt -- расширение compact() в Фазе 2
|
||||
agent/ChatAgent.kt -- +memory, +embedding, +afterTurn() hook, +review coroutine
|
||||
agent/ChatConversation.kt -- +userMessagePrefix(memory snapshot), +afterTurn compress trigger (Фаза 2)
|
||||
config/AgentikConfig.kt -- +memory config block
|
||||
Main.kt -- +memory init, +curator coroutine (Фаза 4)
|
||||
```
|
||||
|
||||
### Изменения в `:proto`
|
||||
|
||||
Не нужны. Память — внутренняя фича standalone, не часть протокола. (Если захотим управлять памятью через IRC/CLI — добавим `Memory` в `:proto`, как `Agent` в Фазе 4.)
|
||||
|
||||
### Изменения в `:server` / `:client`
|
||||
|
||||
Не нужны. Память не идёт через HTTP API в v1. (Только чтение дампа `GET /memory` — это Фаза 4.)
|
||||
|
||||
## 12. Чеклист перед кодом
|
||||
|
||||
- [ ] Подтвердить: канонический store в `agentik.db` (а не отдельный файл).
|
||||
- [ ] Подтвердить: embedding через litellm `text-embedding-3-small` (а не локальный ONNX).
|
||||
- [ ] Подтвердить: brute-force cosine в BLOB (а не sqlite-vss / LanceDB на старте).
|
||||
- [ ] Подтвердить: review-loop как один-shot, не полноценный fork-agent.
|
||||
- [ ] Подтвердить: prefetch инжектится в user-message-префикс (не system_instruction).
|
||||
- [ ] Подтвердить: `MemoryNote.conversation_id` nullable, по умолчанию NULL (глобальная).
|
||||
- [ ] Ответить на вопросы 1–10 из раздела 7.
|
||||
|
||||
После закрытия чеклиста — открываем Фазу 1.
|
||||
@@ -0,0 +1,304 @@
|
||||
# Agentik: Toolsets + Storage Refactor — Implementation Plan
|
||||
|
||||
**Status:** LOCKED. Implementation proceeds autonomously, no mid-implementation
|
||||
pings.
|
||||
|
||||
**Date:** 2026-09-15.
|
||||
|
||||
## Resolved questions (defaults applied)
|
||||
|
||||
- **Q1** (tool-result language): **English** — for consistency with the toolset
|
||||
section in the system prompt (also English).
|
||||
- **Q2** (which info in `Toolset 'X' activated.` text): **Q2-α minimal** —
|
||||
`"Toolset 'X' activated."`, no tool names. Tool descriptions are already in the
|
||||
next request's `tools[]` array, no duplication needed.
|
||||
|
||||
No further open questions.
|
||||
|
||||
---
|
||||
|
||||
## Architectural decisions (locked)
|
||||
|
||||
| Parameter | Decision |
|
||||
|---|---|
|
||||
| Layers | `:agent-core` (existing `:standalone` core), `:agent-toolsets` (new wrapper), `:storage-core` / `:storage-inmemory` / `:storage-sqlite` / `:storage-android` (new). |
|
||||
| Default toolsets | `listOf()`. When empty, zero agent changes: no `enable_toolset` / `disable_toolset` tools, no toolset section in prompt. Full invisibility. |
|
||||
| Activation scope | per-conversation (`conv.id`). |
|
||||
| Dispatch outcome | `Run(value: String) \| Error(message: String)`. `Substituted` variant dropped. |
|
||||
| Auto-activation | Stays in `ToolsetDispatchPolicy` only — never mentioned in the system prompt. Falls through silently when the model "goofed". |
|
||||
| `enable_toolset` / `disable_toolset` audit | H2: written as ordinary `ToolCall` / `ToolResult` rows in SQLite (model sees them in its own history on subsequent turns). |
|
||||
| `ToolsetContribution` fields | `name: String` + `description: String` + `tools: List<NamedTool>`. No `enabledByDefault`. |
|
||||
| Tool naming | `${toolsetName}_${verb}`; prefix = `name`. |
|
||||
| Catalog rendering | Both ACTIVE and INACTIVE rows render as `name — description`, identical format. |
|
||||
| `Toolset` section language | English. |
|
||||
| Tool-result language | English (per Q1). |
|
||||
| Activation timeout | 10 minutes since last use; lazy cleanup on every `activeNames(convId)` call. No background timers. |
|
||||
| Persistence | In-memory only. Not persisted. New conversation = fresh registry. |
|
||||
| Storage | `StorageBundle = MessageStore + WorkingMemoryStore + ReflectionStore + SkillStore`. SQLite is one impl, others possible. |
|
||||
| Skills vs toolsets | Separate concepts. No link between skill index and toolset catalog. |
|
||||
| Module split | A5-γ: extract interfaces, defer actual Android impl. |
|
||||
|
||||
---
|
||||
|
||||
## Module layout (final)
|
||||
|
||||
```
|
||||
agentik/
|
||||
├── storage-core/ NEW (KMP)
|
||||
│ ├── MessageStore / WorkingMemoryStore / ReflectionStore / SkillStore interfaces
|
||||
│ └── StorageBundle aggregator
|
||||
│
|
||||
├── storage-inmemory/ NEW (KMP, tests)
|
||||
│ ├── InMemoryMessageStore
|
||||
│ ├── InMemoryWorkingMemoryStore
|
||||
│ ├── InMemoryReflectionStore
|
||||
│ ├── InMemorySkillStore
|
||||
│ └── InMemoryStorageBundle
|
||||
│
|
||||
├── storage-sqlite/ NEW (JVM, refactor of existing)
|
||||
│ ├── Sqldelight-backed impls of all four stores
|
||||
│ └── SqliteStorageBundle
|
||||
│
|
||||
├── storage-android/ NEW (Android, deferred — placeholder
|
||||
│ └── (placeholder file; full impl comes with Android module)
|
||||
│
|
||||
├── agent-toolsets/ NEW (KMP)
|
||||
│ ├── ToolsetContribution (name + description + tools)
|
||||
│ ├── ToolsetRegistry
|
||||
│ ├── ToolsetDispatchPolicy (wraps inner DispatchPolicy)
|
||||
│ ├── SystemPromptToolsetSection (SystemPromptContributor)
|
||||
│ ├── EnableToolsetTool / DisableToolsetTool (NamedTool)
|
||||
│ └── ToolsetWrapper — not exposed as a separate class; the registry + policy +
|
||||
│ section are constructed and injected individually.
|
||||
│
|
||||
├── standalone/ MODIFIED
|
||||
│ ├── Main.kt — wires new modules (StorageBundle + ToolsetRegistry)
|
||||
│ ├── build.gradle.kts — new dependencies
|
||||
│ ├── ChatAgent / ChatConversation — depends on StorageBundle (interface), not
|
||||
│ │ SqliteStores directly. accept ToolsetRegistry + contributions via ctor.
|
||||
│ └── all existing tests still green.
|
||||
```
|
||||
|
||||
Out of scope (kept in `:standalone` for now): Main, transport adapters (`/agentik`,
|
||||
`/agui`, `/a2a`), LiteLlm backend wiring, debug endpoints, MCP integration.
|
||||
|
||||
---
|
||||
|
||||
## Commit sequence (six commits, each builds + tests green)
|
||||
|
||||
### Commit 1 — `:storage-core` interfaces + bundle
|
||||
|
||||
**New module** `storage-core` (KMP, commonMain only):
|
||||
|
||||
- `MessageStore.kt` — interface (append, getMessages, tokenStats).
|
||||
- `WorkingMemoryStore.kt` — interface (append, list, compact, archive).
|
||||
- `ReflectionStore.kt` — interface (insert, listRecent, count, listForConversation, deleteOlderThan).
|
||||
- `SkillStore.kt` — interface (catalog, upsert, remove, exists, all).
|
||||
- `StorageBundle.kt` — `data class StorageBundle(val messageStore, workingMemoryStore, reflectionStore, skillStore)`.
|
||||
- One umbrella test: `StorageInterfaceContractTest` asserting parameter naming is right (compile-time only).
|
||||
|
||||
**Gradle setup**:
|
||||
- `settings.gradle.kts` — `include(":storage-core")`.
|
||||
- `storage-core/build.gradle.kts` — KMP `commonMain` only with `api kotlinx-coroutines-core`,
|
||||
`api kotlinx-datetime`. No JVM target yet.
|
||||
|
||||
**Verification**:
|
||||
- `./gradlew :storage-core:build` — green.
|
||||
- `./gradlew :storage-core:jvmTest` — green (placeholder test).
|
||||
|
||||
**No `:standalone` modifications yet.**
|
||||
|
||||
### Commit 2 — `:storage-inmemory` impl
|
||||
|
||||
**New module** `storage-inmemory` (KMP, commonMain):
|
||||
|
||||
- All four `InMemory*` implementations backed by `ConcurrentHashMap` + `MutableStateFlow`-ish
|
||||
snapshots for `getMessages(..): Flow<MessageRecord>`.
|
||||
- `InMemoryStorageBundle` factory.
|
||||
|
||||
**Tests** (KMP commonTest):
|
||||
- `InMemoryMessageStoreTest` — append + getMessages (paged flow).
|
||||
- `InMemoryWorkingMemoryStoreTest` — append + list + compact + archive.
|
||||
- `InMemoryReflectionStoreTest` — insert + listRecent + count.
|
||||
- `InMemorySkillStoreTest` — catalog + upsert + remove.
|
||||
|
||||
**Gradle setup**:
|
||||
- Depends on `:storage-core`.
|
||||
|
||||
**Verification**:
|
||||
- `./gradlew :storage-inmemory:allTests` — green.
|
||||
|
||||
### Commit 3 — `:storage-sqlite` refactor
|
||||
|
||||
**New module** `storage-sqlite` (JVM):
|
||||
|
||||
- Move existing `SqliteStores` (and related Sqldelight code) here.
|
||||
- Split into `SqliteMessageStore`, `SqliteWorkingMemoryStore`, `SqliteReflectionStore`,
|
||||
`SqliteSkillStore`.
|
||||
- `SqliteStorageBundle(val db: AgentikDatabase)`. Existing schema/migrations are
|
||||
unchanged. Reads from existing `.sq` files.
|
||||
|
||||
**Migration**:
|
||||
- Existing tests that depend on `SqliteStores` continue to work — keep a thin
|
||||
compat: `SqliteStores` becomes a deprecated alias:
|
||||
```kotlin
|
||||
@Deprecated("Use SqliteStorageBundle")
|
||||
class SqliteStores(db: AgentikDatabase): StorageBundle by SqliteStorageBundle(db)
|
||||
```
|
||||
|
||||
**Tests** (JVM):
|
||||
- Existing sqldelight-backed tests still green.
|
||||
- Add contract tests for new individual stores.
|
||||
|
||||
**Verification**:
|
||||
- `./gradlew :storage-sqlite:jvmTest` — green.
|
||||
- All existing `:standalone` tests that referenced `SqliteStores` still compile
|
||||
(via the @Deprecated alias).
|
||||
|
||||
### Commit 4 — `:agent-toolsets` core
|
||||
|
||||
**New module** `agent-toolsets` (KMP, commonMain):
|
||||
|
||||
- `ToolsetContribution.kt` — data class.
|
||||
- `ToolsetRegistry.kt` — `class ToolsetRegistry(clock: Clock = Clock.System)`.
|
||||
- `activeNames(convId): Set<String>` — lazy cleanup.
|
||||
- `enable(convId, name): String` — 4-case response table (see A2 above).
|
||||
- `disable(convId, name): String` — 4-case response table (see A3 above).
|
||||
- `DEFAULT_TIMEOUT_MS = 10 * 60 * 1000`.
|
||||
- `ToolsetDispatchPolicy.kt` — `interface DispatchPolicy { dispatch(call, sessionId): DispatchOutcome }`,
|
||||
`class ToolsetDispatchPolicy(inner: DispatchPolicy, registry, contributions, coreTools)`:
|
||||
- Dispatch loop:
|
||||
```
|
||||
while (true):
|
||||
if name in resolved (core + active sets) tools: return inner.dispatch(...)
|
||||
if name has prefix matching known toolset T: registry.enable(sessionId, T); continue
|
||||
return Error("tool 'X' not found")
|
||||
```
|
||||
- `SystemPromptToolsetSection.kt` — `class SystemPromptToolsetSection(contributions, enabledSets)`
|
||||
implementing `:agent-core:SystemPromptContributor` (defined here for now;
|
||||
could later live in `:agent-core`).
|
||||
- `EnableToolsetTool.kt` / `DisableToolsetTool.kt` — `NamedTool` implementations.
|
||||
Args schema: `{ "name": "<string>" }` as JSON.
|
||||
- `ToolsetSystemMessages.kt` — companion with `coreToolsDescription: List<String>`
|
||||
(just `["enable_toolset", "disable_toolset"]`).
|
||||
|
||||
**Tests** (commonTest):
|
||||
- `ToolsetRegistryTest`:
|
||||
- enable + activeNames immediately reflects.
|
||||
- enable idempotent (returns "already active").
|
||||
- disable on inactive returns "deactivated" (per A3 case 2).
|
||||
- timeout cleanup via injected `Clock`.
|
||||
- per-conversation isolation (different convIds).
|
||||
- `ToolsetDispatchPolicyTest` — synthetic `ping` toolset:
|
||||
- model calls `ping({})` without enable → auto-enabled, executed, returns "pong".
|
||||
- model calls `enable_toolset({})` with empty args → error message.
|
||||
- model calls `ping({})` with no such toolset registered → error.
|
||||
|
||||
### Commit 5 — `:agent-toolsets` integration (SystemPromptContributor)
|
||||
|
||||
Same module, adds:
|
||||
|
||||
- Define `SystemPromptContributor` interface inside `:agent-toolsets` (or move to
|
||||
`:agent-core`, but `:agent-toolsets` already has it; keep here for now).
|
||||
- `SystemPromptToolsetSection` renders:
|
||||
```
|
||||
## Toolsets
|
||||
Named groups of tools. One set per conversation. Use enable_toolset({"name": X})
|
||||
to add a toolset; disable_toolset({"name": X}) to remove. Both are idempotent.
|
||||
|
||||
ACTIVE
|
||||
name — description
|
||||
|
||||
INACTIVE call enable_toolset({"name": X}) to add
|
||||
name — description
|
||||
...
|
||||
```
|
||||
- `:standalone/Main.kt` — when `contributions.isNotEmpty()`:
|
||||
- Build `ToolsetRegistry()`.
|
||||
- Add `EnableToolsetTool(registry)` and `DisableToolsetTool(registry)` to
|
||||
`ChatAgent.tools`.
|
||||
- Inject `SystemPromptToolsetSection` into the system-prompt contributors chain.
|
||||
- Wrap the `DispatchPolicy` with `ToolsetDispatchPolicy(...)`.
|
||||
|
||||
**Tests**:
|
||||
- `SystemPromptToolsetSectionTest` — renders both ACTIVE and INACTIVE rows identically.
|
||||
- Default (:standalone config) has `contributions = listOf()` → no tools, no
|
||||
prompt section.
|
||||
|
||||
### Commit 6 — `:standalone` swap StorageBundle
|
||||
|
||||
**Changes to `:standalone`**:
|
||||
|
||||
- `Main.kt` — build `SqliteStorageBundle(db)` instead of `SqliteStores(db)`.
|
||||
- `ChatAgent` constructor: `(..., stores: StorageBundle, ...)` (was:
|
||||
`SqliteStores`).
|
||||
- All references to `SqliteStores.messageStore` → `stores.messageStore` (etc.).
|
||||
- `build.gradle.kts` — add `implementation(project(":storage-sqlite"))`,
|
||||
remove direct reliance on sqlite plumbing internals if any.
|
||||
|
||||
**No behaviour change**: existing tests still pass.
|
||||
|
||||
**Verification**:
|
||||
- `./gradlew :standalone:jvmTest` — all green (was 264 tests pre-refactor).
|
||||
- Build `:standalone:fatjar` — works.
|
||||
- Smoke test against `/tmp/agentik-sandbox` — e2e green (model still answers,
|
||||
memory still works, no regressions).
|
||||
|
||||
---
|
||||
|
||||
## Verification at every commit
|
||||
|
||||
After each commit:
|
||||
1. `./gradlew :<module>:build` — green.
|
||||
2. Affected module's tests — green.
|
||||
3. `./gradlew :standalone:jvmTest` — green (no regressions in the biggest test
|
||||
suite). Once `:standalone` starts depending on the new modules in commit 5/6,
|
||||
this becomes the canonical regression check.
|
||||
4. After commit 6 — run the e2e smoke probe against the sandbox (curl `/agentik`
|
||||
`/agui` `/a2a` health + one turn).
|
||||
|
||||
---
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- **Risk**: existing `SqliteStores` references scatter across `:standalone`.
|
||||
**Mitigation**: keep `@Deprecated` alias until all references are swept (commit
|
||||
6); final sweep at commit 7 (deferred).
|
||||
|
||||
- **Risk**: `ChatAgent` ctor signature changes break many call sites.
|
||||
**Mitigation**: introduce `StorageBundle` as a thin ctor param; existing ctors
|
||||
that default to `SqliteStorageBundle(db)` still work.
|
||||
|
||||
- **Risk**: `DispatchPolicy` is currently implicit (direct call to
|
||||
`toolsByName`). Wrapping it from outside may break test doubles.
|
||||
**Mitigation**: introduce `DispatchPolicy` interface in commit 4 alongside the
|
||||
wrapper. Existing fakes gain the one-method interface trivially.
|
||||
|
||||
- **Risk**: toolset section length in prompt — for many toolsets, ~5 lines × N
|
||||
contributions.
|
||||
**Mitigation**: `description` field is short (<100 tokens); limit contributions
|
||||
count via agent config.
|
||||
|
||||
---
|
||||
|
||||
## Open items for future (NOT in this implementation)
|
||||
|
||||
- Android `:storage-android` impl (A5-γ defers this).
|
||||
- Wrapper-class abstraction over (registry + policy + section) once Android
|
||||
needs it.
|
||||
- Test-time Clock injection beyond `ToolsetRegistryTest`.
|
||||
- Pre-validation of `description` text via LLM (probably not worth it).
|
||||
- Exporting `ToolsetDispatchPolicy` to consumers outside `:standalone`.
|
||||
|
||||
---
|
||||
|
||||
## How to resume after context loss
|
||||
|
||||
If this session is compacted and the plan lost:
|
||||
1. Read this file: `agentik/docs/TOOLSETS-PLAN.md`.
|
||||
2. Verify current commit: `git log --oneline -6` — should show commits in the
|
||||
order above.
|
||||
3. Resume from the next commit in the sequence not yet landed.
|
||||
|
||||
If commits 1-3 are landed but no further, jump to commit 4.
|
||||
If commits 1-5 are landed, jump to commit 6.
|
||||
@@ -0,0 +1,158 @@
|
||||
# 01 — Слои модулей (целевое состояние)
|
||||
|
||||
Целевая модульная структура agentik. Снизу вверх:
|
||||
**приложения → runtime → домен → абстракции → платформенные impl**.
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml agentik-module-layers
|
||||
skinparam componentStyle rectangle
|
||||
skinparam ranksep 60
|
||||
skinparam nodesep 30
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title agentik — слои модулей (целевое состояние)
|
||||
|
||||
' --- Applications: entry points (thin wrappers) ---
|
||||
package "Applications\n(entry points, тонкие)" {
|
||||
[Standalone\nHTTP+AG-UI+A2A] as Standalone
|
||||
[AgentikCli\nREPL] as Cli
|
||||
[AgentikAndroid\nCompose UI] as Android
|
||||
}
|
||||
|
||||
' --- Agent runtime ---
|
||||
package "Agent Runtime\n(композиция, lifecycle)" {
|
||||
[AgentCore\nBaseAgent] as AgentCore
|
||||
[AgentBuilder\nDSL] as Builder
|
||||
}
|
||||
|
||||
' --- Background work ---
|
||||
package "Background Work\n(event-driven triggers)" {
|
||||
[BackgroundEvents\nbus + events] as Ev
|
||||
[BackgroundScheduler\npolicy] as Sched
|
||||
}
|
||||
|
||||
' --- Domain logic (generic, переиспользуется) ---
|
||||
package "Domain Logic\n(generic tools)" {
|
||||
[LlmTools\nReflector/Reviewer/Miner] as LlmT
|
||||
[McpBridge\nMCP-SDK → LiteTool] as Mcp
|
||||
[Skills\nparse + store] as Skills
|
||||
}
|
||||
|
||||
' --- Storage abstractions + impls ---
|
||||
package "Storage\n(abstractions)" as StoragePkg {
|
||||
[StorageCore\ninterfaces] as StorageCore
|
||||
}
|
||||
|
||||
package "Storage\n(JVM impls)" {
|
||||
[StorageSqlite\nJDBC] as StorageSql
|
||||
[StorageInmemory\ntests] as StorageInmem
|
||||
}
|
||||
|
||||
package "Storage\n(Android impl)" {
|
||||
[StorageSqliteAndroid\nRoom/sqlite] as StorageSqlA
|
||||
}
|
||||
|
||||
' --- Memory backends ---
|
||||
package "Memory\n(abstractions)" {
|
||||
[MemoryApi\nMemorySystem/MemoryTools] as MemApi
|
||||
}
|
||||
|
||||
package "Memory\n(impls)" {
|
||||
[MemoryMd\nHermes §-files] as MemMd
|
||||
[MemoryVector\nJVector+JVM] as MemVec
|
||||
[MemoryVectorAndroid\nONNX+ANN] as MemVecA
|
||||
}
|
||||
|
||||
' --- LLM backends ---
|
||||
package "LLM\n(abstractions)" {
|
||||
[LitertApi\nLiteLlm контракт] as Litert
|
||||
}
|
||||
|
||||
package "LLM\n(impls)" {
|
||||
[LitertOpenai\nHTTP] as LitertO
|
||||
[LitertGoogle\nLiteRT JVM] as LitertG
|
||||
[LitertAndroid\nLiteRT Android] as LitertA
|
||||
}
|
||||
|
||||
' --- Inter-app protocol ---
|
||||
package "Inter-app" {
|
||||
[Proto\nAgent/Conversation] as Proto
|
||||
[A2AServer] as A2A
|
||||
}
|
||||
|
||||
' --- Зависимости (приложения → runtime → домен → абстракции → платформенные импл) ---
|
||||
Standalone ..> Builder
|
||||
Cli ..> Builder
|
||||
Android ..> Builder
|
||||
|
||||
Builder ..> AgentCore
|
||||
AgentCore ..> Proto
|
||||
AgentCore ..> StorageCore
|
||||
AgentCore ..> MemApi
|
||||
AgentCore ..> Litert
|
||||
AgentCore ..> Mcp
|
||||
AgentCore ..> Skills
|
||||
|
||||
Sched ..> Ev
|
||||
AgentCore ..> Sched
|
||||
AgentCore ..> Ev
|
||||
|
||||
Mcp ..> Litert
|
||||
LlmT ..> Litert
|
||||
|
||||
MemMd ..> MemApi
|
||||
MemVec ..> MemApi
|
||||
MemVecA ..> MemApi
|
||||
|
||||
StorageSql ..> StorageCore
|
||||
StorageInmem ..> StorageCore
|
||||
StorageSqlA ..> StorageCore
|
||||
|
||||
LitertO ..> Litert
|
||||
LitertG ..> Litert
|
||||
LitertA ..> Litert
|
||||
|
||||
Standalone ..> A2A
|
||||
Standalone ..> LitertO
|
||||
Standalone ..> LitertG
|
||||
Standalone ..> StorageSql
|
||||
Standalone ..> MemMd
|
||||
Standalone ..> MemVec
|
||||
Standalone ..> Mcp
|
||||
|
||||
Android ..> LitertA
|
||||
Android ..> StorageSqlA
|
||||
Android ..> MemMd
|
||||
Android ..> MemVecA
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Что показывает
|
||||
|
||||
- **Applications** — три точки входа: web-сервер, CLI REPL, Android-приложение. Каждое тонкое, не содержит бизнес-логики.
|
||||
- **Agent Runtime** — `BaseAgent` + `AgentBuilder` DSL. Вся композиция и lifecycle.
|
||||
- **Background Work** — `BackgroundEvents` (event-bus) + `BackgroundScheduler` (policy подписки). Event-driven, не interval-polling.
|
||||
- **Domain Logic** — generic переиспользуемые модули (`:llm-tools`, `:mcp-bridge`, `:skills`).
|
||||
- **Storage / Memory / LLM** — каждая с абстракцией и одним или несколькими impl (JVM-only или Android-only).
|
||||
- **Inter-app** — `:proto` контракты + `:a2a-server` для межагентного общения.
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ Уже сделано (в этом цикле правок):
|
||||
- `:llm-tools` extracted
|
||||
- `:mcp-bridge` extracted
|
||||
- `BackgroundScheduler` стал event-driven
|
||||
- `ConversationLoop` стал отдельным компонентом (typealias `ChatConversation`)
|
||||
|
||||
⏳ Не сделано:
|
||||
- `:agent-core` (выделить `BaseAgent` + builder в отдельный KMP-модуль)
|
||||
- `:background-events` (выделить events + scheduler — пока в `:standalone`)
|
||||
- `:storage-sqlite-android`
|
||||
- `:memory-vector-android`
|
||||
- `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,127 @@
|
||||
# 02 — Agent Builder: композиция (целевое API)
|
||||
|
||||
Как `AgentBuilder` собирает `BaseAgent` из компонентов. **Memory backend сам объявляет свои tools** — builder их авто-мержит. BackgroundScheduler подписан на события, не interval-poll.
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml agent-composition
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Agent Builder — композиция (целевое API)
|
||||
|
||||
' --- Builder ---
|
||||
rectangle "AgentBuilder" as Builder {
|
||||
rectangle "llm: LiteLlm (обязательно)" as Llm
|
||||
rectangle "storage: StorageBundle (обязательно)" as Storage
|
||||
rectangle "memory: MemorySystem (обязательно)" as Mem
|
||||
rectangle "soul: SoulProvider (default NoopSoul)" as Soul
|
||||
rectangle "tools: List<NamedTool> (авто-сборка из backends)" as Tools
|
||||
rectangle "background: BackgroundConfig (default EmptyBg)" as Bg
|
||||
rectangle "toolset: List<ToolsetContribution> (default empty)" as Ts
|
||||
}
|
||||
|
||||
' --- Backends with their tool side-effects ---
|
||||
rectangle "MemoryMd" as MdMem {
|
||||
interface "MemorySystem" as MemSys
|
||||
interface "List<NamedTool>" as MdTools
|
||||
note right
|
||||
MemoryMd.exposesTools() →
|
||||
memory_save / memory_read /
|
||||
memory_list / memory_delete
|
||||
end note
|
||||
}
|
||||
|
||||
rectangle "McpRegistry" as McpReg {
|
||||
interface "List<NamedTool>" as McpTools
|
||||
note right
|
||||
McpRegistry.namedTools →
|
||||
server__tool1, server__tool2,
|
||||
...
|
||||
end note
|
||||
}
|
||||
|
||||
rectangle "BackgroundEvents" as Events {
|
||||
interface "MutableSharedFlow<CompactionEvent|ToolCallEvent|LifecycleEvent>" as Flow
|
||||
note right
|
||||
Эмитится из:
|
||||
- CompactionCoordinator
|
||||
- ToolDispatcher
|
||||
- ConversationLoop.close()
|
||||
end note
|
||||
}
|
||||
|
||||
rectangle "BackgroundScheduler" as Sched {
|
||||
interface "policy: trigger + debounce" as Policy
|
||||
note right
|
||||
Подписан на Events.
|
||||
НИКАКОГО interval-polling.
|
||||
end note
|
||||
}
|
||||
|
||||
' --- Получаемый Agent ---
|
||||
rectangle "BaseAgent\n(impl: ConversationLoop)" as Agent {
|
||||
rectangle "send / interrupt / events" as API
|
||||
rectangle "BackgroundScheduler\nподписка" as Sub
|
||||
}
|
||||
|
||||
' --- Стрелки зависимостей ---
|
||||
Builder --> Llm
|
||||
Builder --> Storage
|
||||
Builder --> Mem
|
||||
Builder --> Soul
|
||||
Builder --> Tools
|
||||
Builder --> Bg
|
||||
Builder --> Ts
|
||||
|
||||
MdMem --> Mem : implements
|
||||
MdMem --> MdTools : exposes
|
||||
|
||||
McpReg --> McpTools : exposes
|
||||
|
||||
Bg --> Events : subscribes-to
|
||||
Bg --> Sched : holds
|
||||
|
||||
Tools <-- MdTools : auto-merge
|
||||
Tools <-- McpTools : auto-merge
|
||||
|
||||
Builder --> Agent : build()
|
||||
Agent --> API
|
||||
Agent --> Sub
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Целевой Kotlin DSL
|
||||
|
||||
```kotlin
|
||||
val agent = agentBuilder {
|
||||
// Обязательные
|
||||
llm(OpenAiLlm.fromEnv()) // или LitertAndroid.onDevice(context)
|
||||
storage(SqliteStorage(path)) // или SqliteStorage.android(context)
|
||||
memory(MemoryMd(root = "~/memory")) // или MemoryVector(embedding = HttpEmbedding(...))
|
||||
|
||||
// Опциональные
|
||||
soul(FileSoul("~/SOUL.md")) // или HttpSoul(url), NoopSoul()
|
||||
background {
|
||||
// triggers: OnClosing (reflection+mining), OnCompaction(minTurns=10, mining=true)
|
||||
// event-driven, не interval
|
||||
}
|
||||
tools {
|
||||
// memoryMd.exposesTools() + mcpRegistry.namedTools авто-подцепляются
|
||||
+FileReadTool(root = "/data")
|
||||
}
|
||||
toolset {
|
||||
+MemoryToolsToolset(memoryMd)
|
||||
}
|
||||
}.build()
|
||||
```
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
- **`MemoryBackend.exposesTools()`** — backend декларирует свои tools. Не «подставить любой backend», а «backend сообщает что он умеет». Это убирает coupling «какие tools совместимы с какими backends».
|
||||
- **Builder требует только `llm + storage + memory`** как обязательные. Всё остальное — опционально с разумными default'ами (`NoopSoul`, `EmptyBackground`, `empty toolset`).
|
||||
- **`BaseAgent`** — реализация `ConversationLoop` через builder. Конструктор принимает все нужные компоненты. **Один и тот же `BaseAgent` в `:standalone`, `:agentik-cli`, `:agentik-android`** — отличается только wiring через builder.
|
||||
- **BackgroundScheduler подписан на `BackgroundEvents`** — это даёт event-driven по умолчанию. `OnEvery(n)` interval-режим — опциональный fallback (не default).
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,129 @@
|
||||
# 03 — Multi-user chat с mention-detection
|
||||
|
||||
Точка 1 из планов. Один `BaseAgent` обслуживает N пользователей. Отвечает только когда addressed (mention или admin-команда).
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml multi-user-chat
|
||||
skinparam componentStyle rectangle
|
||||
skinparam participantPadding 15
|
||||
skinparam boxPadding 10
|
||||
|
||||
title Multi-user chat с mention-detection (точка 1 из планов)
|
||||
|
||||
' --- Участники ---
|
||||
actor "User A" as UA
|
||||
actor "User B" as UB
|
||||
actor "User C\n(админ)" as UC
|
||||
participant "Telegram /\nSlack /\nMatrix" as Channel
|
||||
participant "AgentRuntime\n(BaseAgent)" as Runtime
|
||||
participant "MentionDetector" as Detector
|
||||
participant "SoulProvider" as Soul
|
||||
participant "MemorySystem\n(MdMemory)" as Memory
|
||||
participant "LlmBackend\n(LiteLlm)" as Llm
|
||||
|
||||
' --- Сценарий ---
|
||||
UA -> Channel : "@bot, что нового?"
|
||||
UB -> Channel : "люблю котов"
|
||||
UC -> Channel : "/bot status"
|
||||
Channel -> Runtime : событие чата
|
||||
|
||||
' --- Внутри Runtime ---
|
||||
Runtime -> Detector : isMentioned(message, botName)
|
||||
|
||||
note right of Detector
|
||||
variants:
|
||||
- SimpleMentionDetector (regex: @bot)
|
||||
- LlmMentionDetector (mini-classifier)
|
||||
- AdminCommandDetector (/command)
|
||||
end note
|
||||
|
||||
Detector --> Runtime : MatchResult{isMentioned, isCommand}
|
||||
|
||||
alt isMentioned или isCommand
|
||||
Runtime -> Soul : read()
|
||||
Runtime -> Memory : prefetch(query, topK)
|
||||
Runtime -> Llm : send(system + history + memory + user)
|
||||
Llm --> Runtime : response + tool_calls
|
||||
Runtime -> Memory : save(decision)
|
||||
Runtime --> Channel : ответ в нужный канал/thread
|
||||
else NOT mentioned и NOT command
|
||||
Runtime -> Runtime : drop (если не админ)
|
||||
note right
|
||||
Не отвечаем, но возможно:
|
||||
- запоминаем факт (memory-only update)
|
||||
- summary на long conversation
|
||||
end note
|
||||
end
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Ключевые модули (что нужно будет добавить)
|
||||
|
||||
### `MentionDetector` interface
|
||||
|
||||
```kotlin
|
||||
interface MentionDetector {
|
||||
data class Result(
|
||||
val isMentioned: Boolean,
|
||||
val isAdminCommand: Boolean,
|
||||
val isPrivateMessage: Boolean, // DM — всегда отвечаем
|
||||
)
|
||||
|
||||
fun detect(message: ChatMessage, botName: String): Result
|
||||
}
|
||||
```
|
||||
|
||||
Имплементации:
|
||||
- `SimpleMentionDetector` — regex `@bot`, `/command` (дешёво, latency ~0)
|
||||
- `LlmMentionDetector` — маленькая классификация через тот же LLM (точнее, но +1 LLM-вызов на каждое сообщение)
|
||||
- `HybridMentionDetector` — fast regex → fallback на LLM только если ambiguous
|
||||
|
||||
### `ChatAdapter` interface
|
||||
|
||||
```kotlin
|
||||
interface ChatAdapter {
|
||||
val channel: String // "telegram" / "slack" / "matrix"
|
||||
suspend fun listen(onMessage: (ChatMessage) -> Unit): Job
|
||||
suspend fun reply(messageId: String, text: String, threadId: String? = null)
|
||||
suspend fun isAdmin(userId: String): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
Имплементации per platform. Каждая адаптирует формат platform → `ChatMessage`.
|
||||
|
||||
### Конфигурация builder'а
|
||||
|
||||
```kotlin
|
||||
agentBuilder {
|
||||
llm(...)
|
||||
storage(...)
|
||||
memory(...)
|
||||
soul(...)
|
||||
background { ... }
|
||||
chat {
|
||||
mentionDetector = HybridMentionDetector(regex = "@bot|@Agent", llmClassifier = false)
|
||||
chatAdapter = TelegramChatAdapter(token = "...")
|
||||
// На каждое сообщение:
|
||||
// 1. mentionDetector.detect()
|
||||
// 2. если isMentioned || isAdminCommand || isPrivate → process
|
||||
// 3. иначе — опционально memory-only save (тихий режим)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Что это даёт
|
||||
|
||||
- Один `BaseAgent` обслуживает чат целиком (один LLM, одна память — общий контекст команды)
|
||||
- `@bot` — explicit invocation, не «agent отвечает на всё подряд»
|
||||
- `/bot status` / `/bot clear-memory` — admin-команды (отдельный канал, без LLM)
|
||||
- DM — всегда отвечает (это личное обращение)
|
||||
- В групповом чате без mention — agent может **молча учить** (memory update без ответа). Полезно для «запомнил что Вася любит котов».
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
⏳ Ничего из этого нет. Сейчас `:standalone` — это HTTP API, к которому подключаются clients. Для multi-user chat нужен новый `:chat-adapter-telegram` (или -slack / -matrix) модуль + `MentionDetector` interface.
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,119 @@
|
||||
# 04 — Sub-agents + A2A между агентами
|
||||
|
||||
Точки 2 и 3 из планов. Orchestrator-агент spawn'ит sub-агентов с изолированным контекстом. Независимые агенты общаются через A2A.
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml sub-agents-and-a2a
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Sub-agents + A2A между агентами (точка 2+3 из планов)
|
||||
|
||||
' --- Orchestrator ---
|
||||
rectangle "OrchestratorAgent\n(BaseAgent + tools)" as Orch {
|
||||
rectangle "ConversationLoop\n(main user)" as MainConv
|
||||
}
|
||||
|
||||
' --- Sub-agent spawn ---
|
||||
rectangle "subAgent(\n task: String,\n config: AgentConfig\n): Flow<SubAgentEvent>" as SpawnAPI
|
||||
note right of SpawnAPI
|
||||
Spawn API — НЕ отдельный модуль,
|
||||
а convenience поверх BaseAgent:
|
||||
val sub = agent.spawnChild(config) {
|
||||
systemPrompt = "..."
|
||||
tools = [ReadTool, WriteTool]
|
||||
memory = EmptyMemory // изолированно
|
||||
}
|
||||
sub.events.collect { ... }
|
||||
end note
|
||||
|
||||
' --- Дочерний агент (изолированный контекст) ---
|
||||
rectangle "SubAgent\n(изолированный scope)" as Sub {
|
||||
rectangle "ConversationLoop\n(child)" as SubConv
|
||||
rectangle "backgroundScope\n(lifecycle scoped)" as SubBg
|
||||
}
|
||||
|
||||
' --- A2A между независимыми агентами ---
|
||||
rectangle "Agent A\n(BaseAgent)" as AgentA
|
||||
rectangle "Agent B\n(BaseAgent)" as AgentB
|
||||
rectangle "A2A Server\n(:a2a-server)" as A2ASrv
|
||||
|
||||
AgentA -> A2ASrv : POST /\n(application/json)
|
||||
A2ASrv -> AgentB : dispatch(message)
|
||||
AgentB --> A2ASrv : response
|
||||
A2ASrv --> AgentA : SSE / JSON-RPC
|
||||
|
||||
' --- Стрелки ---
|
||||
Orch -> SpawnAPI : calls
|
||||
SpawnAPI -> Sub : creates with custom config
|
||||
Sub -> SubBg : has its own
|
||||
Orch -> Orch : main flow continues
|
||||
Sub --> Orch : Flow<SubAgentEvent> emits\n(Started / ToolCalled / ToolResult /\nAssistantMessage / Done / Failed)
|
||||
|
||||
Orch -> A2ASrv : can also delegate to remote agent
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Sub-agents API
|
||||
|
||||
```kotlin
|
||||
sealed interface SubAgentEvent {
|
||||
data class Started(val taskId: String) : SubAgentEvent
|
||||
data class AssistantMessage(val text: String) : SubAgentEvent
|
||||
data class ToolCalled(val toolName: String, val args: JsonObject) : SubAgentEvent
|
||||
data class ToolResult(val toolName: String, val result: String) : SubAgentEvent
|
||||
data class Done(val taskId: String, val finalResult: String) : SubAgentEvent
|
||||
data class Failed(val taskId: String, val error: String) : SubAgentEvent
|
||||
}
|
||||
|
||||
interface BaseAgent {
|
||||
// ... existing methods ...
|
||||
|
||||
/**
|
||||
* Spawn дочерний агент с изолированным контекстом (memory, system prompt,
|
||||
* tools). Возвращает Flow событий жизненного цикла + результата.
|
||||
* Cancellation родителя НЕ отменяет sub-agent — sub-agent живёт до Done/Failed.
|
||||
*/
|
||||
fun spawnChild(config: SubAgentConfig): Flow<SubAgentEvent>
|
||||
}
|
||||
|
||||
data class SubAgentConfig(
|
||||
val systemPrompt: String,
|
||||
val tools: List<NamedTool> = emptyList(),
|
||||
val memory: MemorySystem = EmptyMemory(),
|
||||
val model: LiteLlm? = null, // если null — делит LLM родителя
|
||||
val maxTurns: Int = 10,
|
||||
val timeoutMs: Long = 60_000,
|
||||
)
|
||||
```
|
||||
|
||||
## Зачем изолированный scope
|
||||
|
||||
Sub-agent получает **свою копию контекста**, не делит memory с родителем. Это критично:
|
||||
- `research_subagent` — должен исследовать тему, не отвечать на основные сообщения пользователя
|
||||
- `summarize_subagent` — суммаризировать документ, не трогать основной диалог
|
||||
- `code_review_subagent` — ревьюить PR, не видеть разговор
|
||||
|
||||
Если нужно расшарить контекст — это explicit через `sharedMemory: SharedMemoryHandle` параметр, не default.
|
||||
|
||||
## A2A между независимыми агентами
|
||||
|
||||
Уже есть `:a2a-server` модуль (см. `standalone/build.gradle.kts` — `implementation(libs.a2a.server)`). Использовался для AG-UI/A2A протокола в `:standalone`. Можно переиспользовать для межагентного общения.
|
||||
|
||||
Сценарий: orchestrator-agent не может сам решить задачу → делегирует remote-агенту через A2A → получает response → продолжает. Это уже работающая инфраструктура.
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ Уже есть:
|
||||
- `:a2a-server` подключён
|
||||
- `BaseAgent.spawnChild` — **не существует**, но `ConversationLoop` уже умеет создавать изолированный scope через свой `agentScope` — нужна только обёртка
|
||||
|
||||
⏳ Не сделано:
|
||||
- `SubAgentConfig` + `Flow<SubAgentEvent>` API
|
||||
- `EmptyMemory` (null-object для изолированного scope)
|
||||
- Lifecycle management (parent dies → child должен complete or be cancelled?)
|
||||
- Сериализация sub-agent state для отладки (event log)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,171 @@
|
||||
# 05 — Android Agent Stack
|
||||
|
||||
Что меняется vs `:standalone`. Цель: `BaseAgent` тот же самый, но platform-impl разные (Storage, LLM, Vector Memory, MCP).
|
||||
|
||||

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml android-agent-stack
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Android Agent Stack — что меняется vs Standalone
|
||||
|
||||
' --- Android side ---
|
||||
package "Android Application" {
|
||||
[MainActivity\n(Compose)] as Activity
|
||||
[AndroidAgentRunner\n(workmanager / service)] as Runner
|
||||
[AndroidAgentBuilder] as AndroidBuilder
|
||||
}
|
||||
|
||||
package "Android-specific impls" {
|
||||
[StorageSqliteAndroid\n(Room/sqlite)] as StorageA
|
||||
[MemoryVectorAndroid\n(ONNX runtime + ANN)] as MemVecA
|
||||
[LitertAndroid\n(NNAPI delegate)] as LitertA
|
||||
[SoulFileAndroid\n(context.filesDir)] as SoulA
|
||||
[McpRegistry\nstdio: ProcessBuilder] as McpA
|
||||
}
|
||||
|
||||
' --- Shared (KMP) ---
|
||||
package "Agent Runtime (shared)" {
|
||||
[AgentCore\nBaseAgent] as AgentCore
|
||||
[AgentBuilder] as Builder
|
||||
}
|
||||
|
||||
package "Domain (shared)" {
|
||||
[LlmTools\ncommonMain] as LlmT
|
||||
[BackgroundEvents\ncommonMain] as Ev
|
||||
[McpBridge\njvmMain] as McpB
|
||||
[Skills\ncommonMain] as Skills
|
||||
}
|
||||
|
||||
package "Memory (shared impl)" {
|
||||
[MemoryMd\n(commonMain)] as MemMd
|
||||
[MemoryApi\ninterfaces] as MemApi
|
||||
}
|
||||
|
||||
' --- Зависимости ---
|
||||
Activity --> Runner
|
||||
Runner --> AndroidBuilder
|
||||
AndroidBuilder --> AgentCore
|
||||
|
||||
AndroidBuilder --> StorageA
|
||||
AndroidBuilder --> MemVecA
|
||||
AndroidBuilder --> LitertA
|
||||
AndroidBuilder --> SoulA
|
||||
AndroidBuilder --> McpA
|
||||
|
||||
AgentCore --> LlmT
|
||||
AgentCore --> Ev
|
||||
AgentCore --> McpB
|
||||
AgentCore --> Skills
|
||||
AgentCore --> MemMd
|
||||
|
||||
' --- Главные отличия от Standalone ---
|
||||
note right of LitertA
|
||||
On-device inference.
|
||||
LiteRT с NNAPI delegate →
|
||||
работает на CPU/GPU/NPU
|
||||
прямо на устройстве, без сети.
|
||||
|
||||
vs Standalone: HTTP-only
|
||||
(OpenAI-compatible).
|
||||
end note
|
||||
|
||||
note right of StorageA
|
||||
android.database.sqlite
|
||||
через Room или сырой API.
|
||||
|
||||
vs Standalone: JDBC +
|
||||
Sqlite-JDBC driver
|
||||
(только JVM).
|
||||
end note
|
||||
|
||||
note right of MemVecA
|
||||
JVector JVM-only. На Android
|
||||
нужна альтернатива —
|
||||
ONNX Runtime + какой-нибудь
|
||||
ANN (Annoy/HNSW).
|
||||
|
||||
Или пока без vector memory,
|
||||
только MemoryMd.
|
||||
end note
|
||||
|
||||
note right of McpA
|
||||
MCP через ProcessBuilder
|
||||
на Android работает, но
|
||||
subprocess lifecycle
|
||||
сложнее (foreground service
|
||||
нужен для долгого subprocess).
|
||||
end note
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Что общего с `:standalone`
|
||||
|
||||
**`BaseAgent`, `BackgroundScheduler`, `LlmTools`, `McpBridge`, `Skills`, `MemoryMd` — всё KMP (commonMain).** Android-agent = `:standalone` с другим wiring'ом. Не нужно переписывать agent logic.
|
||||
|
||||
## Что другое
|
||||
|
||||
| Компонент | `:standalone` (JVM) | `:agentik-android` (Android) | Сложность |
|
||||
|---|---|---|---|
|
||||
| Storage | `:storage-sqlite` (JDBC + Sqlite-JDBC) | `:storage-sqlite-android` (Room или raw) | Низкая — тот же `StorageBundle` interface |
|
||||
| LLM | `:litert-openai` (HTTP), `:litert-google` (LiteRT JVM) | `:litert-android` (LiteRT Android, NNAPI delegate) | Средняя — нужен новый модуль |
|
||||
| Vector memory | `:memory-vector` (JVector) | `:memory-vector-android` (ONNX Runtime + HNSW/Annoy) | Высокая — JVector JVM-only, нужна альтернатива |
|
||||
| SOUL provider | `FileSoulProvider` (path) | `SoulFileAndroid` (`context.filesDir`) | Низкая |
|
||||
| MCP | `McpRegistry` (ProcessBuilder, stdio subprocess) | Тот же `McpRegistry`, но subprocess в foreground service | Средняя — нужен Android service |
|
||||
| Embedding | `HttpEmbeddingClient` (HTTP) | Тот же ИЛИ on-device (ONNX) | Средняя |
|
||||
|
||||
## Минимальный Android agent (v1)
|
||||
|
||||
Если не нужны все фичи сразу — минимум:
|
||||
|
||||
```kotlin
|
||||
val agent = androidAgentBuilder(context) {
|
||||
llm(LitertAndroid.onDevice(context, modelPath = "/data/local/tmp/model.litertlm"))
|
||||
storage(SqliteStorage.android(context, "agent.db"))
|
||||
memory(MemoryMd.root(context.filesDir.resolve("memory")))
|
||||
soul(FileSoul(context.filesDir.resolve("SOUL.md")))
|
||||
background {
|
||||
// OnClosing + OnCompaction работают так же как на JVM
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Без MCP, без vector memory (только MemoryMd на файлах), только on-device LLM. Достаточно для off-line агента.
|
||||
|
||||
## Foreground service для MCP
|
||||
|
||||
Если нужны MCP-серверы (например, локальный file-system MCP) — subprocess нужен foreground service чтобы Android не убил его при выключении экрана. Это добавляет сложности:
|
||||
|
||||
```kotlin
|
||||
class McpForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
val proc = ProcessBuilder(command, args).start()
|
||||
// ... route stdio to McpLiteToolAdapter ...
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Пока можно без этого (только если MCP нужен на Android).
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ KMP-ready:
|
||||
- `:llm-tools` (commonMain, платформо-агностик)
|
||||
- `:mcp-bridge` (jvmMain — Android-вариант через `:mcp-bridge-android`)
|
||||
- `:skills` (commonMain)
|
||||
- `:memory-md` (commonMain)
|
||||
- `:proto` (commonMain)
|
||||
|
||||
⏳ Не существует:
|
||||
- `:storage-sqlite-android`
|
||||
- `:memory-vector-android`
|
||||
- `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `:agent-core` (выделить BaseAgent + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,64 @@
|
||||
# agentik — диаграммы архитектуры
|
||||
|
||||
PlantUML-схемы для обсуждения будущей структуры (Android agent, multi-user chat, sub-agents, A2A). Это **целевое состояние**, не текущее.
|
||||
|
||||
## Файлы
|
||||
|
||||
Каждый `.md` содержит:
|
||||
- Краткое описание (что показывает)
|
||||
- **Пред-рендеренный SVG** (``) — гарантированно показывается **везде**
|
||||
- PlantUML source в ` ```plantuml ` блоке — для редактирования (требует Graphviz `dot` для рендеринга)
|
||||
- Дополнительный markdown-текст (что нужно сделать, текущее vs целевое)
|
||||
|
||||
| Файл | Что показывает |
|
||||
|---|---|
|
||||
| [01-module-layers.md](./01-module-layers.md) | Целевая модульная структура (приложения → runtime → домен → абстракции → платформенные impl). Что в каком слое и кто от кого зависит. |
|
||||
| [02-agent-composition.md](./02-agent-composition.md) | Как `AgentBuilder` собирает `BaseAgent` из компонентов. Memory backend сам объявляет свои tools. BackgroundScheduler подписан на события (НЕ interval-poll). |
|
||||
| [03-multi-user-chat.md](./03-multi-user-chat.md) | Сценарий: чат с N пользователями, mention-detection, админ-команды, agent отвечает только когда addressed. |
|
||||
| [04-sub-agents.md](./04-sub-agents.md) | Orchestrator spawn'ит sub-agent с изолированным контекстом, получает `Flow<SubAgentEvent>`. A2A между независимыми агентами через `:a2a-server`. |
|
||||
| [05-android-stack.md](./05-android-stack.md) | Что меняется на Android: on-device LLM (NNAPI), Room/sqlite, ONNX-based vector memory, foreground-service для MCP subprocess. |
|
||||
|
||||
## Почему SVG + PlantUML source
|
||||
|
||||
PlantUML требует Java + (для component/class/deployment диаграмм) Graphviz `dot`. Если `dot` не установлен — рендерер падает с ошибкой "Executable dot does not exist".
|
||||
|
||||
Решение: **пре-рендерим в SVG один раз** и вставляем как `<img>`. Диаграмма гарантированно показывается в любом markdown-viewer (GitHub, IntelliJ, VSCode, GitLab) без зависимостей. PlantUML source в code block остаётся для редактирования.
|
||||
|
||||
## Как редактировать диаграмму
|
||||
|
||||
1. Меняешь PlantUML-source в ` ```plantuml ` блоке `.md` файла.
|
||||
2. Ре-рендеришь SVG:
|
||||
```bash
|
||||
mkdir -p /tmp/plantuml-work && chmod 777 /tmp/plantuml-work
|
||||
cp docs/diagrams/*.md /tmp/plantuml-work/
|
||||
docker run --rm -v /tmp/plantuml-work:/work plantuml/plantuml -tsvg /work/*.md
|
||||
cp /tmp/plantuml-work/*.svg docs/diagrams/
|
||||
```
|
||||
3. Проверяешь что SVG обновился:
|
||||
```bash
|
||||
ls -la docs/diagrams/*.svg
|
||||
```
|
||||
4. Коммитишь оба файла: `.md` (source) и `.svg` (rendered).
|
||||
|
||||
Требует Docker (или локального PlantUML+Graphviz). `apt install graphviz` для Arch/Manjaro.
|
||||
|
||||
## Контекст
|
||||
|
||||
Текущий код движется в эту сторону:
|
||||
- `:llm-tools` extracted ✅
|
||||
- `:mcp-bridge` extracted ✅
|
||||
- `BackgroundScheduler` стал event-driven ✅
|
||||
- `ConversationLoop` стал отдельным компонентом ✅
|
||||
|
||||
Не сделано (см. детали в каждом .md):
|
||||
- `:agent-core` (выделить `BaseAgent` + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
- `:storage-sqlite-android`, `:memory-vector-android`, `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `MentionDetector` interface + adapters для multi-user chat
|
||||
- `BaseAgent.spawnChild` + `Flow<SubAgentEvent>`
|
||||
|
||||
Подробнее:
|
||||
- `STANDALONE-REVIEW.md` — что плохо в текущем коде
|
||||
- `MEMORY-DESIGN.md` — детали memory архитектуры
|
||||
- `STANDALONE.md` — текущий standalone
|
||||
@@ -0,0 +1,12 @@
|
||||
# Default version for local builds only (когда CI/CD не передал -Pversion=<tag>).
|
||||
# Имя ключа специально НЕ 'version' — иначе Gradle-мерж gradle.properties и
|
||||
# -Pversion= возьмёт default из gradle.properties. Передавай через CICD:
|
||||
# ./gradlew ... -Pversion=$(git describe --tags)
|
||||
# см. .gitea/workflows/release.yml (использует -Pversion=$GITHUB_REF_NAME).
|
||||
agentik.version.default=0.1.0-SNAPSHOT
|
||||
|
||||
# KMP jvm target uses JDK 21 for both compilation and toolchain.
|
||||
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
|
||||
|
||||
# Android SDK путь для будущей Android-сборки (пока не используется).
|
||||
# sdk.dir=/home/subochev/Android/Sdk
|
||||
@@ -2,17 +2,30 @@
|
||||
kotlin = "2.4.20"
|
||||
kotlinx-serialization = "1.11.0"
|
||||
kotlinx-coroutines = "1.11.0"
|
||||
kotlinx-io = "0.8.0"
|
||||
ktor = "3.1.3"
|
||||
a2a = "1.0.0-SNAPSHOT"
|
||||
kaml = "0.104.0"
|
||||
litert = "7"
|
||||
litert = "8"
|
||||
sqldelight = "2.3.2"
|
||||
shadow = "8.3.5"
|
||||
jvector = "3.0.6"
|
||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
||||
kotlin-logging = "3.0.5"
|
||||
logback = "1.5.18"
|
||||
mosaic = "0.18.0"
|
||||
clikt = "5.0.3"
|
||||
kotlinx-cli = "0.3.6"
|
||||
|
||||
[plugins]
|
||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
# JetBrains Compose Compiler plugin — обязательно для @Composable в KMP-проектах
|
||||
# с Compose Multiplatform 1.8+; без него @Composable-лямбды ломаются (Function0 вместо Function2).
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
||||
|
||||
[libraries]
|
||||
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
|
||||
@@ -25,7 +38,7 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
|
||||
|
||||
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
|
||||
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
||||
litert-openai = { module = "pw.binom.litert:litert-openai-jvm", version.ref = "litert" }
|
||||
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
|
||||
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
|
||||
|
||||
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
||||
@@ -42,6 +55,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
|
||||
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
||||
@@ -49,9 +63,44 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
||||
# --- Model Context Protocol (MCP) ---
|
||||
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
||||
|
||||
# --- CLI: clikt (ajalt). KMP, native Linux/macOS/Windows включая linuxArm64. ---
|
||||
# https://ajalt.github.io/clikt/
|
||||
# Артефакт один и тот же — `com.github.ajalt.clikt:clikt` — Gradle module
|
||||
# metadata резолвит per-target variant (clikt-jvm / clikt-linuxarm64 / ...).
|
||||
clikt = { module = "com.github.ajalt.clikt:clikt-core", version.ref = "clikt" }
|
||||
kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinx-cli" }
|
||||
|
||||
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
|
||||
# https://github.com/JakeWharton/mosaic
|
||||
mosaic-runtime = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" }
|
||||
mosaic-runtime-jvm = { module = "com.jakewharton.mosaic:mosaic-runtime-jvm", version.ref = "mosaic" }
|
||||
mosaic-runtime-macosx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosx64", version.ref = "mosaic" }
|
||||
mosaic-runtime-macosarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosarm64", version.ref = "mosaic" }
|
||||
mosaic-runtime-linuxx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxx64", version.ref = "mosaic" }
|
||||
mosaic-runtime-linuxarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxarm64", version.ref = "mosaic" }
|
||||
mosaic-runtime-mingwx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-mingwx64", version.ref = "mosaic" }
|
||||
mosaic-tty-terminal = { module = "com.jakewharton.mosaic:mosaic-tty-terminal", version.ref = "mosaic" }
|
||||
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
|
||||
# --- commons ---
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
|
||||
|
||||
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
|
||||
# kmmio-core = { module = "dev.karmakrafts.kmmio:kmmio-core", version = "2.3.1" }
|
||||
|
||||
# --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. ---
|
||||
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||
|
||||
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
|
||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
|
||||
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
||||
|
||||
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" }
|
||||
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
|
||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||
//
|
||||
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
|
||||
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
|
||||
// доступные JVM target'у.
|
||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":memory-api"))
|
||||
api(project(":storage-core"))
|
||||
api(project(":skills"))
|
||||
api(libs.litert.api)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// mu.KotlinLogging — JVM-only, для SkillMiner'а
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.litert.LiteConversation
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.litert.LiteRole
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Один ход диалога в формате, удобном для суммаризации.
|
||||
*
|
||||
* Не тянем из audit log напрямую — работаем со своим упрощённым представлением,
|
||||
* чтобы compaction не зависел от деталей хранения.
|
||||
*/
|
||||
data class SummaryTurn(
|
||||
val userMessage: String,
|
||||
val assistantMessage: String,
|
||||
val createdAt: Instant? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Сжимает список прошлых ходов диалога в короткий markdown-саммари.
|
||||
*
|
||||
* Суммаризация — ответственность **агента**, потому что зависит от модели
|
||||
* (context window, summarization prompt, format). Не кладём в `:memory-api`,
|
||||
* чтобы модуль памяти не знал про LiteLlm.
|
||||
*
|
||||
* Имплементация по умолчанию — [LiteLlmContextCompactor] (один-shot LLM-вызов
|
||||
* по промпту из Hermes `context_compressor.py`).
|
||||
*/
|
||||
fun interface ContextCompactor {
|
||||
suspend fun summarize(turns: List<SummaryTurn>): String
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-реализация [ContextCompactor]. Использует отдельный [LiteConversation]
|
||||
* без tools и без истории — чистый one-shot вызов, который не загрязняет
|
||||
* KV-cache основного диалога.
|
||||
*
|
||||
* Промпт — структура из Hermes `context_compressor.py`:
|
||||
* - Goal
|
||||
* - Active State
|
||||
* - Resolved
|
||||
* - Blocked / Open Questions
|
||||
* - Remaining Work
|
||||
*
|
||||
* Возвращает короткий markdown-блок (≈ 10-20 строк), который встанет в
|
||||
* working memory вместо выкинутых ходов.
|
||||
*/
|
||||
class LiteLlmContextCompactor(
|
||||
private val liteLlm: LiteLlm,
|
||||
private val modelTemperature: Float = 0.2f,
|
||||
) : ContextCompactor {
|
||||
|
||||
override suspend fun summarize(turns: List<SummaryTurn>): String {
|
||||
if (turns.isEmpty()) return ""
|
||||
|
||||
val transcript = turns.joinToString("\n\n") { turn ->
|
||||
val stamp = turn.createdAt?.toString()?.let { "[$it] " } ?: ""
|
||||
buildString {
|
||||
append(stamp).append("USER: ").append(turn.userMessage.trim()).append('\n')
|
||||
append(stamp).append("ASSISTANT: ").append(turn.assistantMessage.trim())
|
||||
}
|
||||
}
|
||||
|
||||
val userPrompt = buildString {
|
||||
appendLine("Transcript of past turns (oldest first):")
|
||||
appendLine("```")
|
||||
append(transcript.take(MAX_TRANSCRIPT_CHARS))
|
||||
if (transcript.length > MAX_TRANSCRIPT_CHARS) appendLine("…(truncated)")
|
||||
appendLine("```")
|
||||
appendLine()
|
||||
appendLine("Produce a compact context summary in this exact structure:")
|
||||
appendLine("- **Goal**: one-line primary objective of this conversation")
|
||||
appendLine("- **Active State**: where we are now / what we are currently doing")
|
||||
appendLine("- **Resolved**: concrete decisions / outputs that are already done")
|
||||
appendLine("- **Blocked / Open Questions**: things still unresolved")
|
||||
appendLine("- **Remaining Work**: explicit next steps")
|
||||
appendLine()
|
||||
appendLine("Keep total length under ~20 lines. Plain markdown, no preamble.")
|
||||
}
|
||||
|
||||
val cfg = LiteConversationConfig(
|
||||
systemInstruction = SYSTEM_PROMPT,
|
||||
initialMessages = emptyList(),
|
||||
tools = emptyList(),
|
||||
temperature = modelTemperature,
|
||||
)
|
||||
val conv: LiteConversation = liteLlm.createConversation(cfg)
|
||||
try {
|
||||
val reply = StringBuilder()
|
||||
conv.sendStreamContents(listOf(LiteContentPart.Text(userPrompt))).collect { delta ->
|
||||
if (delta.text.isNotEmpty()) reply.append(delta.text)
|
||||
}
|
||||
return reply.toString().trim().ifEmpty { "(empty summary)" }
|
||||
} finally {
|
||||
runCatching { conv.close() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_TRANSCRIPT_CHARS: Int = 24_000
|
||||
|
||||
private val SYSTEM_PROMPT = """
|
||||
You are a context compressor for an ongoing AI conversation. Your job is to
|
||||
produce a compact structured summary of past turns so that the conversation
|
||||
can continue without losing the user's goal and current state.
|
||||
|
||||
Be terse and concrete. Prefer bullet points over prose. Never invent facts
|
||||
that are not present in the transcript. Do not address the user — this
|
||||
summary is for internal use by another LLM.
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||
import pw.binom.agentik.memory.MemoryReviewer
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||
import pw.binom.agentik.memory.NewMemoryNote
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
import pw.binom.agentik.storage.Ids
|
||||
import pw.binom.litert.LiteLlm
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Реализация [MemoryReviewer] поверх on-device LLM (LiteLlm / Google LiteRT-LM).
|
||||
*
|
||||
* После каждого хода (или пачки ходов при compaction) зовём LiteLlm с
|
||||
* специальным промптом, который просит модель вернуть JSON со списком
|
||||
* новых заметок и удалений. Парсим руками (см. [ReviewDecisionParser]) —
|
||||
* on-device модели с tool-calling работают ненадёжно, structured output
|
||||
* стабильнее.
|
||||
*
|
||||
* Конструктор принимает `dispatcher` чтобы I/O LiteLlm не блокировал
|
||||
* основной поток. По умолчанию — `Dispatchers.IO` (вытягивается из контекста).
|
||||
*
|
||||
* @param maxExistingFacts сколько последних заметок подмешивать в промпт
|
||||
* как [memory-context], чтобы модель не дублировала уже сохранённые факты.
|
||||
*/
|
||||
class LlmMemoryReviewer(
|
||||
private val liteLlm: LiteLlm,
|
||||
private val store: MemoryStore,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val maxExistingFacts: Int = 30,
|
||||
private val clock: Clock = Clock.System,
|
||||
) : MemoryReviewer {
|
||||
|
||||
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = withContext(dispatcher) {
|
||||
// Снимок существующих заметок — чтобы модель не дублировала
|
||||
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
|
||||
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
|
||||
|
||||
val prompt = ReviewPrompts.reviewUserPrompt(turn, existingFacts)
|
||||
val conv = liteLlm.createConversation(
|
||||
pw.binom.litert.LiteConversationConfig(
|
||||
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
|
||||
temperature = 0.2f,
|
||||
maxTokens = 512,
|
||||
)
|
||||
)
|
||||
val raw = try {
|
||||
conv.send(prompt)
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
ReviewDecisionParser.parse(raw)
|
||||
}
|
||||
|
||||
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision = withContext(dispatcher) {
|
||||
if (turns.isEmpty()) return@withContext MemoryReviewDecision()
|
||||
|
||||
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
|
||||
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
|
||||
|
||||
// Склеиваем все ходы в один промпт — модель посмотрит пакетом и сможет
|
||||
// отсеять дубликаты между ходами.
|
||||
val prompt = buildString {
|
||||
if (existingFacts.isNotBlank()) {
|
||||
appendLine("[memory-context — что уже сохранено]")
|
||||
appendLine(existingFacts)
|
||||
appendLine()
|
||||
}
|
||||
appendLine("[compacted-turns — будет удалено после compaction'а]")
|
||||
turns.forEachIndexed { idx, t ->
|
||||
appendLine()
|
||||
appendLine("--- turn ${idx + 1} ---")
|
||||
appendLine("[user] ${t.userMessage}")
|
||||
appendLine("[assistant] ${t.assistantMessage}")
|
||||
}
|
||||
appendLine()
|
||||
append("Верни JSON:")
|
||||
}
|
||||
|
||||
val conv = liteLlm.createConversation(
|
||||
pw.binom.litert.LiteConversationConfig(
|
||||
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
|
||||
temperature = 0.2f,
|
||||
maxTokens = 1024,
|
||||
)
|
||||
)
|
||||
val raw = try {
|
||||
conv.send(prompt)
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
ReviewDecisionParser.parse(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет решение к store: сохраняет новые заметки, удаляет помеченные.
|
||||
* Возвращает сколько заметок записано/удалено — для метрик.
|
||||
*/
|
||||
suspend fun apply(decision: MemoryReviewDecision, source: pw.binom.agentik.memory.MemorySource): ApplyResult = withContext(dispatcher) {
|
||||
var saved = 0
|
||||
var deleted = 0
|
||||
|
||||
for (note in decision.toSave) {
|
||||
store.upsert(toMemoryNote(note, source))
|
||||
saved++
|
||||
}
|
||||
for (id in decision.toDelete) {
|
||||
if (store.delete(id)) deleted++
|
||||
}
|
||||
ApplyResult(saved = saved, deleted = deleted)
|
||||
}
|
||||
|
||||
private fun toMemoryNote(
|
||||
note: NewMemoryNote,
|
||||
source: pw.binom.agentik.memory.MemorySource,
|
||||
): pw.binom.agentik.memory.MemoryNote {
|
||||
val now = clock.now()
|
||||
return pw.binom.agentik.memory.MemoryNote(
|
||||
id = Ids.new("mem-review"),
|
||||
category = note.category,
|
||||
content = note.content,
|
||||
createdAt = now,
|
||||
lastUsedAt = now,
|
||||
useCount = 0,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
data class ApplyResult(val saved: Int, val deleted: Int)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.agentik.storage.Ids
|
||||
import pw.binom.agentik.storage.Reflection
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* One-shot LLM-размышление о качестве последних ходов диалога.
|
||||
*
|
||||
* Использует structured-output JSON prompt (так же как [LlmMemoryReviewer]):
|
||||
* модель возвращает `{ score: 1-5, summary: "...", weakSpots: ["...", "..."] }`,
|
||||
* парсер [ReflectionParser] возвращает [Reflection].
|
||||
*
|
||||
* Триггер: каждые N ходов (AGENTIK_REFLECTION_INTERVAL, default 10).
|
||||
* Не блокирует основной диалог — вызывается в фоне на [dispatcher].
|
||||
*
|
||||
* @param llm LLM-бэкенд
|
||||
* @param maxTurns сколько последних ходов передавать модели (default 6)
|
||||
* @param maxTokens размер ответа LLM (default 512)
|
||||
* @param dispatcher диспетчер для блокирующего LLM-вызова
|
||||
* @param clock для генерации id/timestamp
|
||||
*/
|
||||
class LlmReflector(
|
||||
private val llm: LiteLlm,
|
||||
val maxTurns: Int = 6,
|
||||
private val maxTokens: Int = 512,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
/**
|
||||
* Reflect по последним [turns]. Возвращает [Reflection] или null если
|
||||
* парсер не смог распарсить (например модель вернула полную ерунду).
|
||||
*
|
||||
* Вызов блокирующий: ~1-3 сек на CPU для on-device LiteRT-LM, ~200-500мс
|
||||
* для OpenAI. Поэтому в проде всегда вызывается из background scope.
|
||||
*/
|
||||
suspend fun reflect(turns: List<ConversationTurn>): Reflection? = withContext(dispatcher) {
|
||||
require(turns.isNotEmpty()) { "need at least one turn to reflect" }
|
||||
val conversation = llm.createConversation(
|
||||
config = LiteConversationConfig(
|
||||
systemInstruction = ReflectionPrompts.SYSTEM_PROMPT,
|
||||
initialMessages = emptyList(),
|
||||
tools = emptyList(),
|
||||
)
|
||||
)
|
||||
try {
|
||||
val userPrompt = ReflectionPrompts.buildUserPrompt(
|
||||
turns = turns.takeLast(maxTurns),
|
||||
maxTurns = maxTurns,
|
||||
)
|
||||
val raw = conversation.send(userPrompt)
|
||||
val parsed = ReflectionParser.parse(raw)
|
||||
?: return@withContext null
|
||||
Reflection(
|
||||
id = Ids.reflection(),
|
||||
conversationId = null, // будет проставлен caller'ом ChatConversation
|
||||
createdAt = clock.now(),
|
||||
turnsAnalyzed = turns.size,
|
||||
score = parsed.score.coerceIn(1, 5),
|
||||
summary = parsed.summary,
|
||||
weakSpots = parsed.weakSpots,
|
||||
)
|
||||
} finally {
|
||||
conversation.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
/**
|
||||
* Минимальный парсер JSON-ответа от [LlmReflector].
|
||||
*
|
||||
* Ожидаемая форма:
|
||||
* ```
|
||||
* {"score": 4, "summary": "...", "weakSpots": ["...", "..."]}
|
||||
* ```
|
||||
*
|
||||
* Допуски:
|
||||
* - Модель может обернуть ответ в ```json ... ``` fences — обрезаем.
|
||||
* - Может быть лидирующий/завершающий текст до/после JSON — находим первую
|
||||
* `{` и парсим баланс скобок до парной `}`.
|
||||
* - `score` может быть числом или строкой ("4") — оба варианта ок.
|
||||
* - `weakSpots` может быть пустым массивом.
|
||||
* - Любые невалидные символы → null (defensive: лучше пропустить рефлексию,
|
||||
* чем уронить agent loop).
|
||||
*/
|
||||
object ReflectionParser {
|
||||
|
||||
data class Parsed(val score: Int, val summary: String, val weakSpots: List<String>)
|
||||
|
||||
fun parse(raw: String): Parsed? {
|
||||
val json = extractJsonObject(raw) ?: return null
|
||||
val score = extractIntField(json, "score") ?: return null
|
||||
val summary = extractStringField(json, "summary") ?: ""
|
||||
val weakSpots = extractStringArrayField(json, "weakSpots") ?: emptyList()
|
||||
return Parsed(score = score, summary = summary, weakSpots = weakSpots)
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает JSON-объект из произвольного текста: обрезает ``` fences,
|
||||
* пропускает префикс/суффикс, ищет первую `{` и парную `}` по балансу скобок.
|
||||
*/
|
||||
internal fun extractJsonObject(raw: String): String? {
|
||||
var s = raw.trim()
|
||||
// Strip ```json / ``` fences
|
||||
if (s.startsWith("```")) {
|
||||
val firstNewline = s.indexOf('\n')
|
||||
if (firstNewline > 0) s = s.substring(firstNewline + 1)
|
||||
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
|
||||
}
|
||||
val open = s.indexOf('{')
|
||||
if (open < 0) return null
|
||||
var depth = 0
|
||||
var i = open
|
||||
var inString = false
|
||||
var escape = false
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (escape) { escape = false; i++; continue }
|
||||
if (c == '\\' && inString) { escape = true; i++; continue }
|
||||
if (c == '"') { inString = !inString; i++; continue }
|
||||
if (!inString) {
|
||||
when (c) {
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) return s.substring(open, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Достаёт числовое поле из JSON-объекта: "score": 4 или "score": "4". */
|
||||
internal fun extractIntField(json: String, name: String): Int? {
|
||||
val re = Regex(""""$name"\s*:\s*(?:(\d+)|"(\d+)")""")
|
||||
val match = re.find(json) ?: return null
|
||||
val n = match.groupValues[1].ifEmpty { match.groupValues[2] }
|
||||
return n.toIntOrNull()
|
||||
}
|
||||
|
||||
/** Достаёт строковое поле: "summary": "..." с `\"` и `\\` escape. */
|
||||
internal fun extractStringField(json: String, name: String): String? {
|
||||
val re = Regex(""""$name"\s*:\s*"((?:\\.|[^"\\])*)"""")
|
||||
val match = re.find(json) ?: return null
|
||||
return unescape(match.groupValues[1])
|
||||
}
|
||||
|
||||
/** Достаёт массив строк: "weakSpots": ["a", "b"]. Возвращает пустой список если поле отсутствует. */
|
||||
internal fun extractStringArrayField(json: String, name: String): List<String>? {
|
||||
val re = Regex(""""$name"\s*:\s*\[([^\]]*)]""")
|
||||
val match = re.find(json) ?: return null
|
||||
val inner = match.groupValues[1]
|
||||
if (inner.isBlank()) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
val itemRe = Regex("""\"((?:\\.|[^\"\\])*)\"""")
|
||||
for (m in itemRe.findAll(inner)) {
|
||||
out.add(unescape(m.groupValues[1]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun unescape(s: String): String = buildString {
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '\\' && i + 1 < s.length) {
|
||||
when (s[i + 1]) {
|
||||
'"' -> append('"')
|
||||
'\\' -> append('\\')
|
||||
'n' -> append('\n')
|
||||
't' -> append('\t')
|
||||
else -> append(s[i + 1])
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
|
||||
/**
|
||||
* Промпты для [LlmReflector] — one-shot self-reflection.
|
||||
*
|
||||
* Стиль: structured-output (модель отвечает JSON, не зовёт тулзы).
|
||||
* Это та же техника, что в `LlmMemoryReviewer`: on-device LiteRT-LM
|
||||
* плохо работает с tool-calling, но стабильно отвечает на JSON-prompt
|
||||
* при явном `respond with JSON` указании.
|
||||
*/
|
||||
object ReflectionPrompts {
|
||||
|
||||
/**
|
||||
* System-prompt для размышления.
|
||||
* Русский — потому что весь остальной agentik тоже ru-flavored
|
||||
* (review-prompt, MemorySystemGuidance и т.п.).
|
||||
*/
|
||||
const val SYSTEM_PROMPT = """Ты — критический аналитик собственной работы ассистента.
|
||||
Тебе дадут последние ходы диалога: пары user/assistant сообщений.
|
||||
Оцени, насколько хорошо ассистент справился с задачами пользователя.
|
||||
|
||||
Шкала score (одно целое число):
|
||||
1 — ассистент путался, галлюцинировал, не отвечал на вопрос, игнорировал контекст.
|
||||
2 — были заметные проблемы (неточные факты, странные ответы).
|
||||
3 — нормальная работа, ничего особенного.
|
||||
4 — хорошая работа, помог пользователю, был полезным.
|
||||
5 — отличная работа: точный, полезный, уместный.
|
||||
|
||||
weakSpots — это массив КОРОТКИХ строк (1-3 слова каждая), конкретные слабые
|
||||
места, которые заметил. Примеры:
|
||||
- "медленно отвечаю на вопросы про X"
|
||||
- "путаю A и B"
|
||||
- "слишком длинные ответы на простые вопросы"
|
||||
- "не помню контекст разговора"
|
||||
|
||||
summary — свободный markdown-комментарий (1-3 предложения): что именно
|
||||
было хорошо, что плохо, что улучшить.
|
||||
|
||||
ВАЖНО: ответь СТРОГО JSON объектом:
|
||||
{"score": <1-5>, "summary": "<markdown>", "weakSpots": ["...", "..."]}
|
||||
|
||||
Никаких пояснений до или после JSON. Только валидный JSON."""
|
||||
|
||||
/**
|
||||
* User-prompt: последние ходы диалога. Каждый ход — пара
|
||||
* `[user] text` / `[assistant] text`. Старые ходы обрезаются до [maxTurns].
|
||||
*/
|
||||
fun buildUserPrompt(turns: List<ConversationTurn>, maxTurns: Int): String = buildString {
|
||||
appendLine("Последние ${turns.size} из $maxTurns ходов диалога:")
|
||||
appendLine()
|
||||
for ((idx, turn) in turns.withIndex()) {
|
||||
appendLine("--- Ход ${idx + 1} ---")
|
||||
appendLine("[user]: ${turn.userMessage}")
|
||||
appendLine("[assistant]: ${turn.assistantMessage}")
|
||||
appendLine()
|
||||
}
|
||||
append("Оцени по шкале и верни JSON.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||
import pw.binom.agentik.memory.NewMemoryNote
|
||||
|
||||
/**
|
||||
* Парсер ответа LLM-review-loop'а.
|
||||
*
|
||||
* LiteLlm (on-device) не имеет надёжного tool-calling flow, поэтому
|
||||
* модель возвращает JSON в plain text. Парсим регуляркой + минимальным
|
||||
* валидатором — если что-то не так, лучше no-op, чем краш.
|
||||
*/
|
||||
object ReviewDecisionParser {
|
||||
|
||||
/**
|
||||
* Парсит ответ модели в [MemoryReviewDecision]. Возвращает пустой decision
|
||||
* если ответ пустой, не JSON, или JSON битый — лучше ничего не сохранить,
|
||||
* чем записать мусор.
|
||||
*/
|
||||
fun parse(rawOutput: String): MemoryReviewDecision {
|
||||
val trimmed = rawOutput.trim()
|
||||
if (trimmed.isEmpty()) return MemoryReviewDecision()
|
||||
|
||||
// Ищем JSON-блок, даже если модель обернула его в ``` или добавила пояснения
|
||||
val json = extractJson(trimmed) ?: return MemoryReviewDecision()
|
||||
return parseJson(json)
|
||||
}
|
||||
|
||||
private fun extractJson(text: String): String? {
|
||||
// Первый '{' до последней '}'
|
||||
val start = text.indexOf('{')
|
||||
val end = text.lastIndexOf('}')
|
||||
if (start < 0 || end < 0 || end <= start) return null
|
||||
return text.substring(start, end + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный JSON-парсер. Не хочу тянуть kotlinx-serialization в этот
|
||||
* слой — JSON простой (плоский массив объектов), пишем руками.
|
||||
*/
|
||||
private fun parseJson(json: String): MemoryReviewDecision {
|
||||
return try {
|
||||
val save = parseArray(json, "save") { obj ->
|
||||
val category = parseString(obj, "category")?.let { runCatching { MemoryCategory.valueOf(it.uppercase()) }.getOrNull() }
|
||||
?: return@parseArray null
|
||||
val content = parseString(obj, "content")?.takeIf { it.isNotBlank() }
|
||||
?: return@parseArray null
|
||||
NewMemoryNote(category, content)
|
||||
}
|
||||
val delete = parseStringArray(json, "delete")
|
||||
MemoryReviewDecision(toSave = save, toDelete = delete)
|
||||
} catch (e: Exception) {
|
||||
MemoryReviewDecision()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит массив объектов из JSON-строки по ключу. callback получает
|
||||
* содержимое одного элемента (без обрамляющих []{} и без имени ключа)
|
||||
* и возвращает элемент результата либо null (пропустить).
|
||||
*/
|
||||
private fun <T> parseArray(json: String, key: String, map: (String) -> T?): List<T> {
|
||||
// Ищем "key": [ ... ]
|
||||
val keyIdx = json.indexOf("\"$key\"")
|
||||
if (keyIdx < 0) return emptyList()
|
||||
val arrayStart = json.indexOf('[', keyIdx)
|
||||
val arrayEnd = json.indexOf(']', arrayStart)
|
||||
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
|
||||
|
||||
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
|
||||
return splitTopLevelObjects(arrayContent).mapNotNull { map(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбивает содержимое JSON-массива на отдельные объекты верхнего уровня
|
||||
* с учётом вложенности и экранирования кавычек.
|
||||
*/
|
||||
private fun splitTopLevelObjects(content: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
var depth = 0
|
||||
var start = -1
|
||||
var inString = false
|
||||
var escaped = false
|
||||
content.forEachIndexed { i, ch ->
|
||||
if (escaped) { escaped = false; return@forEachIndexed }
|
||||
when {
|
||||
ch == '\\' && inString -> escaped = true
|
||||
ch == '"' -> inString = !inString
|
||||
!inString && ch == '{' -> {
|
||||
if (depth == 0) start = i
|
||||
depth++
|
||||
}
|
||||
!inString && ch == '}' -> {
|
||||
depth--
|
||||
if (depth == 0 && start >= 0) {
|
||||
result.add(content.substring(start, i + 1))
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит массив строк из JSON по ключу. Используется для `delete`
|
||||
* (там элементы — голые строки, не объекты).
|
||||
*/
|
||||
private fun parseStringArray(json: String, key: String): List<String> {
|
||||
val keyIdx = json.indexOf("\"$key\"")
|
||||
if (keyIdx < 0) return emptyList()
|
||||
val arrayStart = json.indexOf('[', keyIdx)
|
||||
val arrayEnd = json.indexOf(']', arrayStart)
|
||||
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
|
||||
|
||||
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
|
||||
val result = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < arrayContent.length) {
|
||||
// Skip whitespace and commas
|
||||
while (i < arrayContent.length && (arrayContent[i].isWhitespace() || arrayContent[i] == ',')) i++
|
||||
if (i >= arrayContent.length || arrayContent[i] != '"') break
|
||||
i++ // skip opening quote
|
||||
|
||||
val sb = StringBuilder()
|
||||
var escaped = false
|
||||
while (i < arrayContent.length) {
|
||||
val c = arrayContent[i]
|
||||
if (escaped) {
|
||||
when (c) {
|
||||
'n' -> sb.append('\n')
|
||||
't' -> sb.append('\t')
|
||||
'r' -> sb.append('\r')
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
else -> sb.append(c)
|
||||
}
|
||||
escaped = false
|
||||
i++
|
||||
continue
|
||||
}
|
||||
when (c) {
|
||||
'\\' -> { escaped = true; i++ }
|
||||
'"' -> {
|
||||
result.add(sb.toString())
|
||||
i++
|
||||
break
|
||||
}
|
||||
else -> { sb.append(c); i++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит строковое значение по ключу в JSON-объекте. Возвращает
|
||||
* содержимое без обрамляющих кавычек и с раскрытыми базовыми escape.
|
||||
*/
|
||||
private fun parseString(obj: String, key: String): String? {
|
||||
val keyIdx = obj.indexOf("\"$key\"")
|
||||
if (keyIdx < 0) return null
|
||||
val colon = obj.indexOf(':', keyIdx)
|
||||
if (colon < 0) return null
|
||||
val firstQuote = obj.indexOf('"', colon)
|
||||
if (firstQuote < 0) return null
|
||||
|
||||
// Ищем закрывающую кавычку с учётом escape
|
||||
var i = firstQuote + 1
|
||||
val sb = StringBuilder()
|
||||
while (i < obj.length) {
|
||||
val c = obj[i]
|
||||
when {
|
||||
c == '\\' && i + 1 < obj.length -> {
|
||||
when (val next = obj[i + 1]) {
|
||||
'n' -> sb.append('\n')
|
||||
't' -> sb.append('\t')
|
||||
'r' -> sb.append('\r')
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
else -> sb.append(next)
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
c == '"' -> return sb.toString()
|
||||
else -> {
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
|
||||
/**
|
||||
* Промпты для review-loop'а через LiteLlm.
|
||||
*
|
||||
* Hermes делает это с OpenAI/Anthropic tool-calling flow. У нас on-device
|
||||
* движок (Google LiteRT-LM) — там tool-calling ненадёжен, поэтому используем
|
||||
* structured-output: модель должна вернуть JSON, который мы парсим регуляркой.
|
||||
*/
|
||||
object ReviewPrompts {
|
||||
|
||||
/**
|
||||
* Системная инструкция для review-loop'а. На русском — модель у нас
|
||||
* русскоязычная (gemma-2-9b / qwen / и т.п.), английский промпт часто
|
||||
* даёт хуже результат на ru-данных.
|
||||
*/
|
||||
const val REVIEW_SYSTEM_PROMPT = """Ты — агент ревью памяти. Твоя задача — проанализировать пару (сообщение пользователя, ответ ассистента) и решить, что из неё стоит сохранить в долговременную память.
|
||||
|
||||
Категории памяти:
|
||||
- USER — факты о пользователе (имя, профессия, предпочтения, контекст его жизни)
|
||||
- WORLD — факты о внешнем мире (проекты, технологии, организации, конкретные API/документация)
|
||||
- PREFERENCE — предпочтения по формату/поведению ассистента (стиль кода, длины ответов, инструменты)
|
||||
|
||||
Правила:
|
||||
1. Сохраняй ТОЛЬКО durable facts — то, что останется актуальным через недели. Не сохраняй "пользователь поздоровался" или "ассистент использовал grep".
|
||||
2. Не дублируй уже сохранённое — если факт уже есть в [memory-context], пропусти.
|
||||
3. Каждый факт — одна короткая фраза. Не абзацы, не "the user mentioned...".
|
||||
4. Не выдумывай. Если ничего достойного — верни пустой массив.
|
||||
|
||||
Формат ответа — строго JSON без обрамления ```json и без пояснений:
|
||||
{"save":[{"category":"USER|WORLD|PREFERENCE","content":"..."}],"delete":[]}
|
||||
|
||||
Если нечего сохранять:
|
||||
{"save":[],"delete":[]}"""
|
||||
|
||||
/**
|
||||
* Форматирует user-prompt для review-loop'а. Подаёт текущий ход +
|
||||
* текущее состояние долговременной памяти (чтобы избежать дубликатов).
|
||||
*/
|
||||
fun reviewUserPrompt(
|
||||
turn: ReviewedTurn,
|
||||
existingFacts: String,
|
||||
): String = buildString {
|
||||
if (existingFacts.isNotBlank()) {
|
||||
appendLine("[memory-context — что уже сохранено]")
|
||||
appendLine(existingFacts)
|
||||
appendLine()
|
||||
}
|
||||
appendLine("[user]")
|
||||
appendLine(turn.userMessage)
|
||||
appendLine()
|
||||
appendLine("[assistant]")
|
||||
appendLine(turn.assistantMessage)
|
||||
appendLine()
|
||||
append("Верни JSON:")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import mu.KotlinLogging
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteLlm
|
||||
|
||||
private val log = mu.KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* Фоновый минер скилов (skill mining): сетка безопасности для skill self-improvement.
|
||||
*
|
||||
* Модель в ходе разговора может "протупить" и не вызвать `skill_save`, хотя приём
|
||||
* был действительно переиспользуемым. [SkillMiner] периодически (каждые N ходов,
|
||||
* см. `AGENTIK_SKILL_MINING_INTERVAL`) берёт последние ходы диалога, показывает их
|
||||
* LLM вместе с текущим каталогом скилов и просит structured-output JSON:
|
||||
* {"skills": [{"name","description","body"}]}. Найденные скилы upsert-ятся в
|
||||
* [pw.binom.agentik.skills.SkillStore] — агент становится умнее между сессиями
|
||||
* даже без прямого tool-call в ходе разговора.
|
||||
*
|
||||
* Архитектурно — точный аналог [LlmReflector]: короткоживущий LiteConversation
|
||||
* (один прогон = один LLM-вызов), blocking-инференс на [dispatcher], defensive
|
||||
* парсинг [SkillMiningParser] (кривой ответ → пустой список, не ломает agent loop).
|
||||
*
|
||||
* @param llm LLM-бэкенд
|
||||
* @param maxTurns сколько последних ходов передавать модели (default 30)
|
||||
* @param maxTokens потолок ответа модели (default 1536 — body скила бывает длинным)
|
||||
* @param dispatcher диспетчер для блокирующего LLM-вызова
|
||||
*/
|
||||
class SkillMiner(
|
||||
private val llm: LiteLlm,
|
||||
val maxTurns: Int = 30,
|
||||
private val maxTokens: Int = 1536,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
/**
|
||||
* Mine по последним [turns] с учётом текущего каталога [existing].
|
||||
*
|
||||
* @return найденные/обновлённые скилы; пустой список — нечего сохранять
|
||||
* или модель ответила мусором (defensive: прогон просто пропускается).
|
||||
*
|
||||
* Вызов блокирующий: ~1-3 с на CPU для on-device LiteRT-LM. Всегда вызывать
|
||||
* из background scope (хук [pw.binom.agentik.standalone.agent.ChatConversation]
|
||||
* или debug-эндпоинт).
|
||||
*/
|
||||
suspend fun mine(turns: List<ConversationTurn>, existing: List<SkillFile>): List<SkillFile> =
|
||||
withContext(dispatcher) {
|
||||
val batch = turns.takeLast(maxTurns)
|
||||
if (batch.isEmpty()) return@withContext emptyList()
|
||||
val conversation = llm.createConversation(
|
||||
config = LiteConversationConfig(
|
||||
systemInstruction = SkillMiningPrompts.SYSTEM_PROMPT,
|
||||
initialMessages = emptyList(),
|
||||
tools = emptyList(),
|
||||
temperature = 0.2f,
|
||||
maxTokens = maxTokens,
|
||||
)
|
||||
)
|
||||
try {
|
||||
val userPrompt = SkillMiningPrompts.buildUserPrompt(batch, existing)
|
||||
val raw = conversation.send(userPrompt)
|
||||
val mined = SkillMiningParser.parse(raw)
|
||||
if (mined.isNotEmpty()) {
|
||||
log.info { "skill-mine: found ${mined.size} skill(s) from ${batch.size} turns: ${mined.map { it.name }}" }
|
||||
}
|
||||
mined
|
||||
} catch (e: Throwable) {
|
||||
log.warn(e) { "skill-mine: LLM call failed, skipping pass" }
|
||||
emptyList()
|
||||
} finally {
|
||||
conversation.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
|
||||
/**
|
||||
* Минимальный парсер JSON-ответа [SkillMiner] (в том же defensive-стиле, что
|
||||
* [ReflectionParser] и [ReviewDecisionParser]: без kotlinx-serialization, чтобы
|
||||
* кривой ответ локальной модели не ронял agent loop).
|
||||
*
|
||||
* Ожидаемая форма:
|
||||
* ```
|
||||
* {"skills": [{"name": "x", "description": "...", "body": "..."}]}
|
||||
* ```
|
||||
* Допуски:
|
||||
* - ответ может быть обёрнут в ```json ... ``` fences;
|
||||
* - может быть текст до/после JSON — ищем первый `{` (или `[`) и балансируем;
|
||||
* - допустим и голый массив `[{...}, {...}]` без ключа `"skills"`;
|
||||
* - `body` может содержать `\n`, `\"`, `\\` — деэскейпим;
|
||||
* - `description`/`body` могут отсутствовать (тогда пустые);
|
||||
* - любой мусор (невалидные кавычки, незакрытые скобки) → пустой список,
|
||||
* mining-прогон просто не сохранит ничего.
|
||||
*/
|
||||
object SkillMiningParser {
|
||||
|
||||
fun parse(raw: String): List<SkillFile> {
|
||||
val blob = extractJsonBlob(raw) ?: return emptyList()
|
||||
val array = extractSkillsArray(blob) ?: return emptyList()
|
||||
return splitTopLevelObjects(array).mapNotNull { obj ->
|
||||
val name = extractStringField(obj, "name")?.trim().orEmpty()
|
||||
if (name.isEmpty()) return@mapNotNull null
|
||||
val description = extractStringField(obj, "description")?.trim().orEmpty()
|
||||
val body = extractStringField(obj, "body")?.trim().orEmpty()
|
||||
SkillFile(name = name, description = description, body = body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрезает ``` fences, находит первый `{` или `[` и возвращает подстроку
|
||||
* до парной закрывающей (со знанием состояний string/escape).
|
||||
*/
|
||||
internal fun extractJsonBlob(raw: String): String? {
|
||||
var s = raw.trim()
|
||||
if (s.startsWith("```")) {
|
||||
val nl = s.indexOf('\n')
|
||||
if (nl > 0) s = s.substring(nl + 1)
|
||||
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
|
||||
}
|
||||
val open = minOf(
|
||||
s.indexOf('{').takeIf { it >= 0 } ?: Int.MAX_VALUE,
|
||||
s.indexOf('[').takeIf { it >= 0 } ?: Int.MAX_VALUE,
|
||||
)
|
||||
if (open == Int.MAX_VALUE) return null
|
||||
val opener = s[open]
|
||||
val closer = if (opener == '{') '}' else ']'
|
||||
var depth = 0
|
||||
var inString = false
|
||||
var escape = false
|
||||
for (i in open until s.length) {
|
||||
val c = s[i]
|
||||
if (escape) { escape = false; continue }
|
||||
if (inString) {
|
||||
when (c) {
|
||||
'\\' -> escape = true
|
||||
'"' -> inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
when (c) {
|
||||
'"' -> inString = true
|
||||
opener, '{', '[' -> depth++
|
||||
'}', ']' -> {
|
||||
depth--
|
||||
if (depth == 0 && ((c == closer) || (opener == '{' && c == '}') || (opener == '[' && c == ']'))) {
|
||||
return s.substring(open, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит массив скилов: если в blob есть ключ `"skills"` — массив после него,
|
||||
* иначе сам blob (если начинается с `[`).
|
||||
*/
|
||||
internal fun extractSkillsArray(blob: String): String? {
|
||||
val keyIdx = blob.indexOf("\"skills\"")
|
||||
if (keyIdx >= 0) {
|
||||
val colon = blob.indexOf(':', keyIdx + "skills".length + 2)
|
||||
if (colon < 0) return null
|
||||
val open = blob.indexOf('[', colon + 1)
|
||||
if (open < 0) return null
|
||||
return balanceArray(blob, open)
|
||||
}
|
||||
if (blob.startsWith('[')) return blob.drop(1).dropLast(1)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Балансирует `[...]` от [start] (включительно). Возвращает содержимое без скобок. */
|
||||
private fun balanceArray(s: String, start: Int): String? {
|
||||
var depth = 0
|
||||
var inString = false
|
||||
var escape = false
|
||||
for (i in start until s.length) {
|
||||
val c = s[i]
|
||||
if (escape) { escape = false; continue }
|
||||
if (inString) {
|
||||
when (c) {
|
||||
'\\' -> escape = true
|
||||
'"' -> inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
when (c) {
|
||||
'"' -> inString = true
|
||||
'[' -> depth++
|
||||
']' -> {
|
||||
depth--
|
||||
if (depth == 0) return s.substring(start + 1, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Разбивает содержимое массива на топ-уровневые `{...}` объекты (string-aware). */
|
||||
internal fun splitTopLevelObjects(arrayContent: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < arrayContent.length) {
|
||||
if (arrayContent[i] == '{') {
|
||||
var depth = 0
|
||||
var inString = false
|
||||
var escape = false
|
||||
var j = i
|
||||
while (j < arrayContent.length) {
|
||||
val c = arrayContent[j]
|
||||
if (escape) { escape = false; j++; continue }
|
||||
if (inString) {
|
||||
when (c) {
|
||||
'\\' -> escape = true
|
||||
'"' -> inString = false
|
||||
}
|
||||
} else {
|
||||
when (c) {
|
||||
'"' -> inString = true
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) {
|
||||
out.add(arrayContent.substring(i, j + 1))
|
||||
i = j + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
if (j >= arrayContent.length) break
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Достаёт первое строковое поле `"<name>": "..."` из JSON-объекта с
|
||||
* поддержкой escapes (`\"`, `\\`, `\n`, `\t`). `null` если поля нет.
|
||||
*/
|
||||
internal fun extractStringField(obj: String, name: String): String? {
|
||||
val keyRe = Regex(""""$name"\s*:""")
|
||||
val keyMatch = keyRe.find(obj) ?: return null
|
||||
val colonIdx = keyMatch.range.last
|
||||
// Пропускаем пробельные символы после :
|
||||
var i = colonIdx + 1
|
||||
while (i < obj.length && (obj[i] == ' ' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == '\t')) i++
|
||||
if (i >= obj.length || obj[i] != '"') {
|
||||
// Значение не строка (null/число) — не поддерживаем.
|
||||
return null
|
||||
}
|
||||
i++ // открывающая кавычка
|
||||
val sb = StringBuilder()
|
||||
while (i < obj.length) {
|
||||
val c = obj[i]
|
||||
if (c == '\\' && i + 1 < obj.length) {
|
||||
when (val esc = obj[i + 1]) {
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
'n' -> sb.append('\n')
|
||||
't' -> sb.append('\t')
|
||||
'r' -> sb.append('\r')
|
||||
else -> sb.append(esc)
|
||||
}
|
||||
i += 2
|
||||
} else if (c == '"') {
|
||||
return sb.toString()
|
||||
} else {
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Незакрытая строка — мусор от модели, считаем null.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
|
||||
/**
|
||||
* Промпты для [SkillMiner]: structured-output JSON.
|
||||
*
|
||||
* Тот же подход, что [ReflectionPrompts] и [LlmMemoryReviewer]: локальной модели
|
||||
* (LiteRT-LM) не доверяем tool-calling, поэтому просим строго JSON и парсим руками
|
||||
* ([SkillMiningParser]).
|
||||
*/
|
||||
object SkillMiningPrompts {
|
||||
|
||||
/**
|
||||
* System prompt. Задаёт роль "минёра скилов": модель смотрит на последние
|
||||
* ходы разговора и решает, есть ли в них переиспользуемый приём, который
|
||||
* стоит закрепить в скиле, чтобы агент становился умнее между сессиями.
|
||||
*
|
||||
* Ключевое отличие от `skill_save` (в-ходе): mining — это сетка безопасности,
|
||||
* если модель в ходе разговора забыла сохранить скил. Поэтому промпт жёсткий
|
||||
* по критериям: только реально переиспользуемое, без дублей каталога.
|
||||
*/
|
||||
val SYSTEM_PROMPT: String = """
|
||||
Ты — фоновый минер навыков (skill miner) для ИИ-агента.
|
||||
|
||||
Тебе показывают последние ходы разговора агента с пользователем и каталог
|
||||
его текущих навыков (скилов). Твоя задача — найти в этих ходах переиспользуемый
|
||||
приём, процедуру или паттерн, который агент применит в БУДУЩИХ, других
|
||||
разговорах. Такой приём закрепляется как скил, и агент с ним становится
|
||||
умнее.
|
||||
|
||||
Критерии, ЧТО сохращать:
|
||||
- конкретная воспроизводимая процедура (шаги, команды, форматы, приёмы);
|
||||
- решение проблемы, которое модель выработала и в другом разговоре повторит;
|
||||
- проверка/валидация, о которой модель "забыла" и её стоит закрепить.
|
||||
|
||||
ЧТО НЕ сохраняй:
|
||||
- разовые факты конкретного разговора (это не приём, а данные);
|
||||
- тривиальность ("ответь кратко"), которую и так видно из контекста;
|
||||
- дубли уже существующих скилов в каталоге — если приём уже есть,
|
||||
предложи ОБНОВЛЕНИЕ (то же имя, улучшённый body), а не новый скил.
|
||||
|
||||
Вывод — строго JSON, без пояснений до и после:
|
||||
{"skills": [{"name": "...", "description": "...", "body": "..."}]}
|
||||
Если сохранять нечего — {"skills": []}
|
||||
|
||||
Правила по полям:
|
||||
- name: kebab-case или иерархия через двоеточие (например, "backend:spring:db-base");
|
||||
короткое, 2-5 слов. Если обновляешь существующий скил — ТОЧНО его имя.
|
||||
- description: 1-2 предложения, когда/зачем применять скил.
|
||||
- body: markdown-инструкция: краткое описание + нумерованные шаги + примеры.
|
||||
Только то, что агент должен помнить; без воды.
|
||||
""".trimIndent()
|
||||
|
||||
/**
|
||||
* User prompt: последние [turns] разговора + каталог существующих скилов.
|
||||
* Ходы нумеруются, чтобы модель понимала хронологию.
|
||||
*/
|
||||
fun buildUserPrompt(turns: List<ConversationTurn>, existing: List<SkillFile>): String = buildString {
|
||||
appendLine("### Последние ходы разговора (по хронологии)")
|
||||
turns.forEachIndexed { i, t ->
|
||||
appendLine()
|
||||
appendLine("--- Ход ${i + 1} ---")
|
||||
appendLine("[user] ${t.userMessage.take(1500)}")
|
||||
appendLine("[assistant] ${t.assistantMessage.take(2000)}")
|
||||
}
|
||||
appendLine()
|
||||
appendLine("### Текущий каталог скилов (не дубли, обновляй при необходимости)")
|
||||
if (existing.isEmpty()) {
|
||||
appendLine("(пока нет)")
|
||||
} else {
|
||||
for (s in existing) {
|
||||
appendLine("- ${s.name}: ${s.description.take(160)}")
|
||||
}
|
||||
}
|
||||
appendLine()
|
||||
appendLine("Если есть что сохранить (или обновить существующий) — выведи JSON. Иначе {\"skills\": []}.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generic MCP (Model Context Protocol) bridge — переиспользуемый модуль,
|
||||
// который превращает любой MCP-сервер (stdio subprocess или HTTP endpoint)
|
||||
// в набор [LiteTool]-адаптеров.
|
||||
//
|
||||
// Вынесен из :standalone — MCP не специфичен для standalone'а, это generic
|
||||
// мост между MCP-SDK и litert-kmp. Может переиспользоваться в :agentik-cli
|
||||
// или :agentik-tui когда те снова включатся.
|
||||
//
|
||||
// Зависимости:
|
||||
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
|
||||
// - litert.api для LiteTool контракта
|
||||
// - MCP SDK (JVM-only)
|
||||
// - Ktor client (для StreamableHttpClientTransport)
|
||||
// - kotlinx-serialization для парсинга конфига
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":agent-toolsets"))
|
||||
|
||||
api(libs.litert.api)
|
||||
|
||||
implementation(libs.mcp.sdk.client)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
+5
-3
@@ -1,4 +1,5 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -57,13 +58,14 @@ data class McpConfig(
|
||||
val isEmpty: Boolean get() = servers.isEmpty()
|
||||
|
||||
companion object {
|
||||
private val log = mu.KotlinLogging.logger {}
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun fromEnv(env: (String) -> String? = System::getenv): McpConfig {
|
||||
val path = env("AGENTIK_MCP_CONFIG")?.takeIf { it.isNotBlank() } ?: return empty()
|
||||
val file = File(path)
|
||||
if (!file.exists()) {
|
||||
System.err.println("[agentik] AGENTIK_MCP_CONFIG points to missing file: $path")
|
||||
log.warn { "AGENTIK_MCP_CONFIG points to missing file: $path" }
|
||||
return empty()
|
||||
}
|
||||
return fromJson(file.readText())
|
||||
@@ -96,7 +98,7 @@ data class McpConfig(
|
||||
?: emptyMap()
|
||||
return McpServerSpec.Stdio(name = name, command = command, args = args, env = env)
|
||||
}
|
||||
System.err.println("[agentik] MCP server '$name' has neither 'url' nor 'command' — skipped")
|
||||
log.warn { "MCP server '$name' has neither 'url' nor 'command' — skipped" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
+21
-21
@@ -1,4 +1,6 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
import mu.KotlinLogging
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
@@ -28,9 +30,9 @@ import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.standalone.agent.NamedTool
|
||||
import pw.binom.litert.LiteTool
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
/**
|
||||
* Реестр подключённых MCP-серверов.
|
||||
@@ -42,6 +44,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*
|
||||
* [close] убивает stdio-процессы и закрывает HTTP-клиент.
|
||||
*/
|
||||
private val log = KotlinLogging.logger {}
|
||||
class McpRegistry(
|
||||
private val servers: List<McpServerSpec>,
|
||||
private val httpClient: HttpClient = defaultHttpClient(),
|
||||
@@ -72,10 +75,10 @@ class McpRegistry(
|
||||
val server = connectOne(spec)
|
||||
connected[spec.name] = server
|
||||
}.onFailure { e ->
|
||||
System.err.println("[agentik] MCP server '${spec.name}' failed to connect: ${e.message}")
|
||||
log.warn { "MCP server '${spec.name}' failed to connect: ${e.message}" }
|
||||
}
|
||||
}
|
||||
System.err.println("[agentik] MCP registry: ${connected.size}/${servers.size} servers connected, ${allTools.size} tools total")
|
||||
log.warn { "MCP registry: ${connected.size}/${servers.size} servers connected, ${allTools.size} tools total" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +87,7 @@ class McpRegistry(
|
||||
val transport: Transport = when (spec) {
|
||||
is McpServerSpec.Stdio -> {
|
||||
val cmd = (listOf(spec.command) + spec.args).joinToString(" ")
|
||||
System.err.println("[agentik] MCP stdio '$spec.name': $cmd")
|
||||
log.warn { "MCP stdio '$spec.name': $cmd" }
|
||||
val pb = ProcessBuilder(buildList { add(spec.command); addAll(spec.args) })
|
||||
.redirectErrorStream(false)
|
||||
spec.env.forEach { (k, v) -> pb.environment()[k] = v }
|
||||
@@ -97,7 +100,7 @@ class McpRegistry(
|
||||
)
|
||||
}
|
||||
is McpServerSpec.Http -> {
|
||||
System.err.println("[agentik] MCP http '$spec.name': ${spec.url}")
|
||||
log.warn { "MCP http '$spec.name': ${spec.url}" }
|
||||
StreamableHttpClientTransport(
|
||||
client = httpClient.config {
|
||||
if (spec.headers.isNotEmpty()) {
|
||||
@@ -155,17 +158,15 @@ class McpRegistry(
|
||||
* Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий
|
||||
* между MCP-серверами (например, оба могут иметь tool `search`).
|
||||
*
|
||||
* [describe] сериализует tool в JSON-дескриптор в формате, который litert-openai
|
||||
* и litert-google принимают как function-calling definition:
|
||||
* [describe] сериализует tool в JSON-дескриптор — flat OpenAPI-спецификация
|
||||
* (формат, который напрямую принимает LiteRT-LM; litert-openai сам оборачивает
|
||||
* её в OpenAI-формат):
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "type": "function",
|
||||
* "function": {
|
||||
* "name": "<server>__<tool>",
|
||||
* "description": "...",
|
||||
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
|
||||
* }
|
||||
* "name": "<server>__<tool>",
|
||||
* "description": "...",
|
||||
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
@@ -184,12 +185,11 @@ internal class McpLiteToolAdapter(
|
||||
|
||||
override fun describe(): String =
|
||||
buildJsonObject {
|
||||
put("type", "function")
|
||||
put("function", buildJsonObject {
|
||||
put("name", fullName)
|
||||
put("description", tool.description ?: "")
|
||||
put("parameters", tool.inputSchema.toJsonSchema())
|
||||
})
|
||||
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
|
||||
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
|
||||
put("name", fullName)
|
||||
put("description", tool.description ?: "")
|
||||
put("parameters", tool.inputSchema.toJsonSchema())
|
||||
}.toString()
|
||||
|
||||
override fun invoke(arguments: String): String {
|
||||
@@ -230,7 +230,7 @@ internal class McpLiteToolAdapter(
|
||||
val parsed = json.parseToJsonElement(raw)
|
||||
if (parsed !is JsonObject) emptyMap() else parsed.toAnyMap()
|
||||
} catch (e: Throwable) {
|
||||
System.err.println("[agentik] MCP tool '$toolName' got invalid args JSON: ${e.message}")
|
||||
log.warn { "MCP tool '$toolName' got invalid args JSON: ${e.message}" }
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# `:memory-api` — контракт долговременной памяти (KMP, jvm + native)
|
||||
|
||||
## Что это
|
||||
|
||||
Интерфейсы долговременной памяти агента:
|
||||
|
||||
- `MemoryStore` — append-only журнал `MemoryNote(id, content, createdAt)`.
|
||||
- `MemoryCategory` — discriminator (`USER`, `WORLD`, `PREFERENCE`,
|
||||
кастомные).
|
||||
- `MemoryNote` — структурная единица памяти; immutable.
|
||||
- Прелоадер / ревьювер по контракту, не по реализации.
|
||||
|
||||
Решает: как единая абстракция позволяет иметь одновременно файловую
|
||||
память (`:memory-md`), SQLite + ANN (`:memory-vector`) и тестовую
|
||||
in-memory (в `:standalone/tests`). Агент работает с `MemoryStore`,
|
||||
не с конкретным бэкендом.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:memory-md` — Hermes-style `§`-файлы (user.md / world.md /
|
||||
preference.md).
|
||||
- `:memory-vector` — SQLite + JVector + LLM-эмбеддинги.
|
||||
- `:standalone` подключает обе реализации и переключает через
|
||||
`AGENTIK_MEMORY_BACKEND=md|vector|off`.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:memory-api:0.1.0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Артефакт публикуется в `caffeine`.
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-memory-api`.
|
||||
|
||||
## Что в API
|
||||
|
||||
```kotlin
|
||||
interface MemoryStore {
|
||||
suspend fun save(category: MemoryCategory, content: String): MemoryNote
|
||||
suspend fun query(category: MemoryCategory?, q: String, limit: Int = 10): List<MemoryNote>
|
||||
suspend fun all(category: MemoryCategory? = null): List<MemoryNote>
|
||||
}
|
||||
|
||||
enum class MemoryCategory(val path: String) {
|
||||
USER("user"),
|
||||
WORLD("world"),
|
||||
PREFERENCE("preference");
|
||||
}
|
||||
|
||||
data class MemoryNote(
|
||||
val id: String,
|
||||
val category: MemoryCategory,
|
||||
val content: String,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :memory-api:allTests
|
||||
```
|
||||
|
||||
Контрактные тесты на Kotlin Multiplatform (без jvmTest-специфики).
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никаких конкретных storage — это API. Backend-ы в `:memory-md` и
|
||||
`:memory-vector`.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Контракт стабильный.
|
||||
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// Чистый KMP commonMain — модели и интерфейсы памяти, без платформенного IO.
|
||||
// Зеркалит набор :proto / :server. Конкретные бэкенды (MD, SQLite+vector)
|
||||
// живут в отдельных модулях и могут таргетить только нужное подмножество.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
/**
|
||||
* Категория факта в долговременной памяти.
|
||||
*
|
||||
* - [USER] — о пользователе (кто он, чем занимается, привычки).
|
||||
* - [WORLD] — о мире/проектах (стек, инструменты, люди, окружение).
|
||||
* - [PREFERENCE] — как пользователь хочет, чтобы агент работал.
|
||||
*/
|
||||
enum class MemoryCategory(val id: String) {
|
||||
USER("user"),
|
||||
WORLD("world"),
|
||||
PREFERENCE("preference");
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): MemoryCategory =
|
||||
entries.firstOrNull { it.id == id }
|
||||
?: throw IllegalArgumentException("unknown memory category: $id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Одна запись в долговременной памяти агента.
|
||||
*
|
||||
* @property id уникальный идентификатор (`mem-<uuid>` по умолчанию).
|
||||
* @property category категория факта.
|
||||
* @property content полный текст заметки (одно-два предложения на практике).
|
||||
* @property createdAt время создания.
|
||||
* @property lastUsedAt когда последний раз заметка выдавалась в prefetch.
|
||||
* @property useCount сколько раз выдавалась в prefetch (для ранжирования).
|
||||
* @property conversationId если не null — заметка привязана к конкретному диалогу;
|
||||
* null — глобальная (дефолт).
|
||||
* @property source как попала в память.
|
||||
*/
|
||||
data class MemoryNote(
|
||||
val id: String,
|
||||
val category: MemoryCategory,
|
||||
val content: String,
|
||||
val createdAt: Instant,
|
||||
val lastUsedAt: Instant,
|
||||
val useCount: Int = 0,
|
||||
val conversationId: String? = null,
|
||||
val source: MemorySource,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
/**
|
||||
* Recall: достать релевантные факты для следующего хода (например — последнее
|
||||
* сообщение пользователя). Результат инжектится в user-message-префикс
|
||||
* контекстным блоком перед отправкой в LLM.
|
||||
*
|
||||
* Бэкенды могут реализовать как keyword-search (MD), так и семантический
|
||||
* поиск по эмбеддингам (vector-store).
|
||||
*
|
||||
* Метод обязан вызывать [MemoryStore.markUsed] для каждой выданной заметки
|
||||
* (если хочет корректный учёт recency/useCount).
|
||||
*/
|
||||
interface MemoryPrefetcher {
|
||||
suspend fun prefetch(
|
||||
query: String,
|
||||
topK: Int = 10,
|
||||
category: MemoryCategory? = null,
|
||||
): List<MemoryNote>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user