Compare commits
43 Commits
0faad45f3d
...
cicd/test
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,89 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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)"
|
||||||
|
|
||||||
|
- name: Build :agentik-tui shadowJar
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :agentik-tui:shadowJar \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
test -f agentik-tui/build/libs/agentik-tui-all.jar \
|
||||||
|
&& echo "shadowJar OK: $(du -h agentik-tui/build/libs/agentik-tui-all.jar)"
|
||||||
|
|
||||||
|
- name: Upload shadowJars
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: agentik-jars
|
||||||
|
path: |
|
||||||
|
standalone/build/libs/standalone-all.jar
|
||||||
|
agentik-cli/build/libs/agentik-cli-all.jar
|
||||||
|
agentik-tui/build/libs/agentik-tui-all.jar
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
||||||
|
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine", а также
|
||||||
|
# собирает fatjar'ы запускаемых модулей и прикрепляет их к релизу как
|
||||||
|
# бинарные ассеты.
|
||||||
|
#
|
||||||
|
# Требуемые Gitea Action Variables:
|
||||||
|
# BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/
|
||||||
|
# Требуемые Gitea Action Secrets:
|
||||||
|
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
|
||||||
|
# RELEASE_TOKEN — Gitea API-токен с правами на запись в релиз (используется
|
||||||
|
# forgejo-release action для прикрепления fatjar'ов к release).
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-libraries:
|
||||||
|
name: Publish KMP libraries → caffeine Nexus
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
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: Publish libraries (all KMP targets, all modules)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
|
||||||
|
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
|
||||||
|
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
|
||||||
|
run: |
|
||||||
|
./gradlew \
|
||||||
|
"-Pversion=${GITEA_REF_NAME}" \
|
||||||
|
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
|
||||||
|
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
|
||||||
|
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
|
||||||
|
publish \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
|
||||||
|
build-fatjars:
|
||||||
|
name: Build runnable fatjars
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
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 :standalone shadowJar
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :standalone:shadowJar \
|
||||||
|
-Pdisable-javadoc=true \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
|
||||||
|
- name: Build :agentik-cli shadowJar
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :agentik-cli:shadowJar \
|
||||||
|
-Pdisable-javadoc=true \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
|
||||||
|
- name: Build :agentik-tui shadowJar
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :agentik-tui:shadowJar \
|
||||||
|
-Pdisable-javadoc=true \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
|
||||||
|
- name: Upload fatjars as release assets
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: agentik-fatjars
|
||||||
|
path: |
|
||||||
|
standalone/build/libs/standalone-*-all.jar
|
||||||
|
agentik-cli/build/libs/agentik-cli-*-all.jar
|
||||||
|
agentik-tui/build/libs/agentik-tui-*-all.jar
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Attach to release
|
||||||
|
uses: https://git.binom.pw/actions/forgejo-release@v1
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
url: ${{ github.server_url }}
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
files: |
|
||||||
|
standalone/build/libs/standalone-*-all.jar
|
||||||
|
agentik-cli/build/libs/agentik-cli-*-all.jar
|
||||||
|
agentik-tui/build/libs/agentik-tui-*-all.jar
|
||||||
+7
-1
@@ -18,4 +18,10 @@ out/
|
|||||||
|
|
||||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
.cortexkit/
|
.cortexkit/
|
||||||
.veai/
|
.veai/
|
||||||
|
|
||||||
|
# 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,157 @@
|
|||||||
# agentik
|
# 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 REPL-клиент (JLine) к /agentik
|
||||||
|
├── agentik-tui/ Compose-for-Mosaic TUI-клиент (desktop) к /agentik
|
||||||
|
└── 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-all.jar
|
||||||
|
|
||||||
|
# TUI
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-tui-0.1.0-all.jar
|
||||||
|
|
||||||
|
# curl
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модули
|
||||||
|
|
||||||
|
- Запускаемые:
|
||||||
|
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||||
|
- [`:agentik-cli`](agentik-cli/README.md) — REPL-клиент (JLine).
|
||||||
|
- [`:agentik-tui`](agentik-tui/README.md) — Compose-for-Mosaic TUI.
|
||||||
|
- Библиотеки (контракты и реализации):
|
||||||
|
- [`: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()
|
||||||
@@ -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,84 @@
|
|||||||
|
# `:agentik-cli` — JVM CLI клиент к `/agentik`
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
JVM-only REPL-клиент к серверу `:standalone` через `:client`
|
||||||
|
над HTTP+SSE:
|
||||||
|
|
||||||
|
- Нативный REPL с JLine (стрелки влево/вправо/вверх, история,
|
||||||
|
Ctrl-D/E).
|
||||||
|
- Подписка на live-стрим событий агента.
|
||||||
|
- Slash-команды: `/new /list /switch /rename /rm /interrupt /history
|
||||||
|
/pwd /help /exit /quit`.
|
||||||
|
- Persistent session id в `~/.agentik/cli-state.json`.
|
||||||
|
|
||||||
|
Решает: быстрый способ проверить агента руками из терминала.
|
||||||
|
Используется в CI-смоук-тестах и для daily-driver.
|
||||||
|
|
||||||
|
## Как запустить
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- JVM 21+ (на машине должна быть JAVA_HOME или `java` в PATH).
|
||||||
|
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
|
||||||
|
|
||||||
|
### Запуск из готового fatjar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-all.jar \
|
||||||
|
--server http://192.168.76.166:8080/agentik
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск через Gradle (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :agentik-cli: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 и выходит |
|
||||||
|
|
||||||
|
## Slash-команды (внутри REPL)
|
||||||
|
|
||||||
|
| Команда | Синонимы | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `/help` | | Показывает help |
|
||||||
|
| `/new [title]` | | Создать диалог |
|
||||||
|
| `/list` | `/ls` | Список диалогов |
|
||||||
|
| `/switch <id>` | `/sw`, `/cd` | Переключиться на диалог |
|
||||||
|
| `/rename <title>` | | Переименовать текущий диалог |
|
||||||
|
| `/rm [id]` | `/delete` | Удалить (текущий или по id) |
|
||||||
|
| `/interrupt` | `/stop`, `/cancel` | Прервать текущий ход |
|
||||||
|
| `/history` | `/h`, `/hist` | Показывает историю текущего диалога |
|
||||||
|
| `/pwd` | | Путь к state-file |
|
||||||
|
| `/exit`, `/quit` | | Выйти |
|
||||||
|
|
||||||
|
## Переменные среды (пробрасываются серверу через `--server`)
|
||||||
|
|
||||||
|
См. [`../standalone/README.md`](../standalone/README.md). На стороне
|
||||||
|
клиента они **не** интерпретируются — это лишь настройки запуска
|
||||||
|
агента. CLI только знает, по какому URL стучаться.
|
||||||
|
|
||||||
|
## Известное ограничение
|
||||||
|
|
||||||
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
|
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||||
|
terminal. Это upstream-особенность Ktor SSE.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :agentik-cli:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
23 теста: парсер slash-команд, event-рендер, state-repository.
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
|
||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
import org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9,
|
||||||
|
// но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть.
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto.
|
||||||
|
// commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only)
|
||||||
|
// и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual,
|
||||||
|
// расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь.
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
// :client JVM-only (ktor-cio). Подключаем только в jvmMain.
|
||||||
|
implementation(project(":client"))
|
||||||
|
// JLine для readline с историей и completion.
|
||||||
|
implementation(libs.jline)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
// runTest { } — suspend test runner для commonTest.
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
|
||||||
|
}
|
||||||
|
jvmTest.dependencies {
|
||||||
|
// JUnit нужен в jvmTest — kotlin-test на JVM = JUnit4.
|
||||||
|
implementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
jvm {
|
||||||
|
binaries {
|
||||||
|
executable {
|
||||||
|
mainClass.set("pw.binom.agentik.cli.MainKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fatjar (uberjar) ---
|
||||||
|
//
|
||||||
|
// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`.
|
||||||
|
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register.
|
||||||
|
|
||||||
|
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"))
|
||||||
|
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.cli.MainKt"
|
||||||
|
attributes["Implementation-Title"] = "agentik-cli"
|
||||||
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
includeEmptyDirs = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
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 pw.binom.agentik.proto.Message
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главный класс REPL.
|
||||||
|
*
|
||||||
|
* Управляет:
|
||||||
|
* - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]);
|
||||||
|
* - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer].
|
||||||
|
* - персистентностью сессии (восстановление последнего диалога при перезапуске CLI).
|
||||||
|
*
|
||||||
|
* Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения.
|
||||||
|
* `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий
|
||||||
|
* увидит [Event.Interrupted] и сам завершится.
|
||||||
|
*/
|
||||||
|
class AgentikCli internal constructor(private val config: CliConfig) {
|
||||||
|
|
||||||
|
private val agent: Agent = CliPlatform.openAgent(baseUrl = config.server, id = config.id)
|
||||||
|
private val terminal: CliTerminal = CliPlatform.openTerminal(
|
||||||
|
historyFile = if (config.historyEnabled) stateFilePath() else null,
|
||||||
|
prompt = "agentik> ",
|
||||||
|
)
|
||||||
|
private val sessionRepo = SessionRepository(
|
||||||
|
filePath = if (config.historyEnabled) stateFilePath() else null,
|
||||||
|
io = CliPlatform.sessionIo(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private var currentConv: Conversation? = null
|
||||||
|
private var currentTitle: String? = null
|
||||||
|
private var lastEventAt: Instant = Instant.DISTANT_PAST
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
|
suspend fun run() {
|
||||||
|
try {
|
||||||
|
// Восстановление сессии.
|
||||||
|
val saved = sessionRepo.load()
|
||||||
|
if (saved != null) {
|
||||||
|
val conv = runCatching { agent.getConversation(saved.conversationId) }
|
||||||
|
.getOrNull()
|
||||||
|
if (conv != null) {
|
||||||
|
currentConv = conv
|
||||||
|
currentTitle = conv.title
|
||||||
|
lastEventAt = saved.lastEventAt
|
||||||
|
terminal.printSystem(
|
||||||
|
"восстановлен диалог ${shorten(conv.id)}" +
|
||||||
|
" (${conv.title ?: "без названия"})",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
terminal.printSystem(
|
||||||
|
"прошлый диалог ${shorten(saved.conversationId)} больше не существует",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printBanner()
|
||||||
|
|
||||||
|
// Главный цикл.
|
||||||
|
while (scope.isActive) {
|
||||||
|
terminal.print(prompt())
|
||||||
|
val line = terminal.readLine() ?: break // EOF → выходим
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isEmpty()) continue
|
||||||
|
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
when (val r = parseSlash(trimmed.substring(1))) {
|
||||||
|
is ParseResult.Success -> {
|
||||||
|
if (handleCommand(r.command) == CommandResult.Exit) break
|
||||||
|
}
|
||||||
|
is ParseResult.Failure -> terminal.printSystem(r.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleUserMessage(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
terminal.printSystem("до свидания.")
|
||||||
|
currentConv?.close()
|
||||||
|
terminal.close()
|
||||||
|
sessionRepo.close()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ banner / prompt
|
||||||
|
|
||||||
|
private suspend fun printBanner() {
|
||||||
|
terminal.println()
|
||||||
|
terminal.println("agentik-cli — id=${config.id} — type /help")
|
||||||
|
terminal.println("server: ${config.server}")
|
||||||
|
when (val c = currentConv) {
|
||||||
|
null -> terminal.println("диалог: не выбран — начните с /new или /switch <id>")
|
||||||
|
else -> terminal.println("диалог: ${shorten(c.id)} (${c.title ?: "без названия"})")
|
||||||
|
}
|
||||||
|
terminal.println()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prompt(): String = "agentik${if (currentConv != null) "" else " (-)"}> "
|
||||||
|
|
||||||
|
private suspend fun printHelp() {
|
||||||
|
terminal.println(
|
||||||
|
"""
|
||||||
|
|Slash-команды:
|
||||||
|
| /help эта справка
|
||||||
|
| /new [title] создать новый диалог
|
||||||
|
| /list, /ls список диалогов (новые сверху)
|
||||||
|
| /switch <id>, /sw переключиться на диалог по id
|
||||||
|
| /rename <title> переименовать текущий диалог
|
||||||
|
| /delete [<id>], /rm удалить диалог (по id или текущий)
|
||||||
|
| /history, /h последние сообщения текущего диалога
|
||||||
|
| /interrupt, /stop прервать текущий ход
|
||||||
|
| /pwd показать текущий диалог
|
||||||
|
| /exit, /quit выйти (Ctrl-D тоже)
|
||||||
|
|
|
||||||
|
|Любой ввод без ведущего `/` отправляется агенту в текущий диалог.
|
||||||
|
""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ command dispatch
|
||||||
|
|
||||||
|
private suspend fun handleCommand(cmd: SlashCommand): CommandResult = when (cmd) {
|
||||||
|
SlashCommand.Help -> { printHelp(); CommandResult.Continue }
|
||||||
|
SlashCommand.Exit, SlashCommand.Quit -> CommandResult.Exit
|
||||||
|
is SlashCommand.New -> { handleNew(cmd.title); CommandResult.Continue }
|
||||||
|
SlashCommand.List -> { handleList(); CommandResult.Continue }
|
||||||
|
is SlashCommand.Switch -> { handleSwitch(cmd.id); CommandResult.Continue }
|
||||||
|
is SlashCommand.Rename -> { handleRename(cmd.title); CommandResult.Continue }
|
||||||
|
is SlashCommand.Delete -> { handleDelete(cmd.id); CommandResult.Continue }
|
||||||
|
SlashCommand.Interrupt -> { handleInterrupt(); CommandResult.Continue }
|
||||||
|
SlashCommand.History -> { handleHistory(); CommandResult.Continue }
|
||||||
|
SlashCommand.Pwd -> { handlePwd(); CommandResult.Continue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleNew(title: String?) {
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
if (title != null) conv.rename(title)
|
||||||
|
currentConv = conv
|
||||||
|
currentTitle = title ?: conv.title
|
||||||
|
lastEventAt = Instant.DISTANT_PAST
|
||||||
|
terminal.printSystem("создан диалог ${shorten(conv.id)}" + if (title != null) " — «$title»" else "")
|
||||||
|
sessionRepo.save(conv.id, lastEventAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleList() {
|
||||||
|
terminal.println("диалоги (новые сверху):")
|
||||||
|
agent.getConversations(offset = 0).collect { conv ->
|
||||||
|
val marker = if (conv.id == currentConv?.id) "*" else " "
|
||||||
|
val title = conv.title ?: "(без названия)"
|
||||||
|
terminal.println(" $marker ${shorten(conv.id)} $title [${conv.updatedAt}]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSwitch(id: String) {
|
||||||
|
val conv = agent.getConversation(id)
|
||||||
|
if (conv == null) {
|
||||||
|
terminal.printSystem("диалог $id не найден")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentConv?.close()
|
||||||
|
currentConv = conv
|
||||||
|
currentTitle = conv.title
|
||||||
|
lastEventAt = Instant.DISTANT_PAST
|
||||||
|
sessionRepo.save(conv.id, lastEventAt)
|
||||||
|
terminal.printSystem("переключились на ${shorten(conv.id)} (${conv.title ?: "без названия"})")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleRename(title: String) {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога — /new")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.rename(title)
|
||||||
|
currentTitle = title
|
||||||
|
terminal.printSystem("заголовок: $title")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleDelete(id: String?) {
|
||||||
|
val target = id ?: currentConv?.id
|
||||||
|
if (target == null) {
|
||||||
|
terminal.printSystem("нет диалога для удаления")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val ok = agent.deleteConversation(target)
|
||||||
|
if (ok) {
|
||||||
|
terminal.printSystem("удалён ${shorten(target)}")
|
||||||
|
if (target == currentConv?.id) {
|
||||||
|
currentConv?.close()
|
||||||
|
currentConv = null
|
||||||
|
currentTitle = null
|
||||||
|
sessionRepo.clear()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
terminal.printSystem("диалог ${shorten(target)} не найден")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleInterrupt() {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.interrupt()
|
||||||
|
terminal.printSystem("прерывание отправлено")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handlePwd() {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("диалог: не выбран")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
terminal.printSystem("id: ${c.id}")
|
||||||
|
terminal.printSystem("title: ${c.title ?: "—"}")
|
||||||
|
terminal.printSystem("updatedAt: ${c.updatedAt}")
|
||||||
|
terminal.printSystem("temporal: ${c.isTemporal}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleHistory() {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
terminal.println("история:")
|
||||||
|
c.getMessages(after = Instant.DISTANT_PAST).collect { msg -> renderHistoryMessage(msg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun renderHistoryMessage(msg: Message) {
|
||||||
|
val prefix = " [${msg.date}] "
|
||||||
|
when (msg) {
|
||||||
|
is Message.UserMessage ->
|
||||||
|
terminal.println(prefix + "user | " + msg.content.text())
|
||||||
|
is Message.AssistantMessage ->
|
||||||
|
terminal.println(prefix + "agent | " + msg.content.text())
|
||||||
|
is Message.ToolCall ->
|
||||||
|
terminal.println(prefix + "tool>${msg.toolName} | ${msg.toolArgs.take(160)}")
|
||||||
|
is Message.ToolResult ->
|
||||||
|
terminal.println(prefix + "tool< | " + (msg.result?.take(160) ?: "null"))
|
||||||
|
is Message.Error ->
|
||||||
|
terminal.println(prefix + "<error${msg.code?.let { "/$it" } ?: ""}> ${msg.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Content>.text(): String =
|
||||||
|
joinToString(separator = "") { c ->
|
||||||
|
when (c) {
|
||||||
|
is Content.Text -> c.body
|
||||||
|
is Content.Image -> "[image:${c.mime}:${c.data.size}B]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ user-message
|
||||||
|
|
||||||
|
private suspend fun handleUserMessage(text: String) {
|
||||||
|
val conv = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога — /new")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.println() // пустая строка для визуального отделения блока
|
||||||
|
|
||||||
|
val renderer = EventRenderer(terminal)
|
||||||
|
val turnFinished = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
// Подписчик events: принимает события и обновляет lastEventAt,
|
||||||
|
// по терминальному событию закрывает Deferred.
|
||||||
|
val eventsJob = scope.launch {
|
||||||
|
try {
|
||||||
|
conv.events(after = lastEventAt).collect { ev ->
|
||||||
|
renderer.render(ev)
|
||||||
|
if (ev.date > lastEventAt) {
|
||||||
|
lastEventAt = ev.date
|
||||||
|
sessionRepo.save(conv.id, lastEventAt)
|
||||||
|
}
|
||||||
|
if (ev is Event.End || ev is Event.Interrupted || ev is Event.Error) {
|
||||||
|
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
|
||||||
|
if (t !is kotlinx.coroutines.CancellationException) {
|
||||||
|
terminal.printSystem("[events stream error] ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
conv.send(listOf(Content.Text(text)))
|
||||||
|
turnFinished.await()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
terminal.printSystem("[send error] ${t.message}")
|
||||||
|
} finally {
|
||||||
|
eventsJob.cancel()
|
||||||
|
renderer.close()
|
||||||
|
terminal.println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ utils
|
||||||
|
|
||||||
|
private fun shorten(id: String): String = id.take(8)
|
||||||
|
|
||||||
|
private fun stateFilePath(): String? {
|
||||||
|
val home = CliPlatform.homeDir() ?: return null
|
||||||
|
val dir = "$home/.agentik"
|
||||||
|
return "$dir/cli-state.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class CommandResult { Continue, Exit }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформенные зависимости CLI. Все вещи, требующие JVM-stdlib или
|
||||||
|
* нативных API (терминал, env, файловое IO для state-файла, HTTP-клиент),
|
||||||
|
* предоставляются здесь как `expect/actual`.
|
||||||
|
*
|
||||||
|
* Текущий статус: jvmMain полностью реализован (JLine + `java.io` + `:client`),
|
||||||
|
* nativeMain — заглушки (подключение native ktor-движков и termios — отдельная задача).
|
||||||
|
*/
|
||||||
|
expect object CliPlatform {
|
||||||
|
fun openAgent(baseUrl: String, id: String): Agent
|
||||||
|
|
||||||
|
fun openTerminal(
|
||||||
|
historyFile: String?,
|
||||||
|
prompt: String,
|
||||||
|
): CliTerminal
|
||||||
|
|
||||||
|
/** HOME/USERPROFILE для пути пути state-файла; null если недоступна. */
|
||||||
|
fun homeDir(): String?
|
||||||
|
|
||||||
|
/** Переменная среды (native API). Для jvmMain — `System.getenv`. */
|
||||||
|
fun env(key: String): String?
|
||||||
|
|
||||||
|
/** Файловое IO для session-state; nativeMain возвращает no-op. */
|
||||||
|
fun sessionIo(): SessionIo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Абстракция терминала, нужная для REPL. suspend-методы, чтобы не блокировать
|
||||||
|
* event-loop агентного цикла во время ожидания ввода.
|
||||||
|
*/
|
||||||
|
interface CliTerminal {
|
||||||
|
val prompt: String
|
||||||
|
|
||||||
|
/** Следующая строка пользователя (без prompt). null = EOF (Ctrl-D/Ctrl-Z). */
|
||||||
|
suspend fun readLine(): String?
|
||||||
|
|
||||||
|
/** Печатает строку + перевод строки. */
|
||||||
|
suspend fun println(text: String = "")
|
||||||
|
|
||||||
|
/** Печатает строку без перевода (для streamed chunks). */
|
||||||
|
suspend fun print(text: String)
|
||||||
|
|
||||||
|
/** Подсветить prompt (символы-разделители сообщений, системные баннеры и т.п.). */
|
||||||
|
suspend fun printSystem(text: String)
|
||||||
|
|
||||||
|
/** Закрыть терминал: restore raw mode, flush history file, ... */
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Печатает [Event] в человеко-читаемом виде через [CliTerminal].
|
||||||
|
*
|
||||||
|
* Дизайн:
|
||||||
|
* - [Event.StartReasoning] — просто системный маркер; текст мысли НЕ выводим
|
||||||
|
* отдельным форматом (см. proto: reasonig текст идёт через [Event.AppendText]).
|
||||||
|
* - [Event.StartResponse] с `responseType=TEXT` — начало печати ответа; закрытие
|
||||||
|
* происходит при [Event.End] или [Event.Interrupted].
|
||||||
|
* - [Event.AppendText] — кусок текста, печатается БЕЗ перевода строки (чанки).
|
||||||
|
* - [Event.AppendImage] — выводим как `[image: <mime>, <bytes> bytes]` placeholder.
|
||||||
|
* Реальный рендеринг сделаем позже через iTerm/Kitty протоколы.
|
||||||
|
* - [Event.End] / [Event.Interrupted] — закрывают текущий блок.
|
||||||
|
* - [Event.Error] — отдельный системный блок `[error: …]`.
|
||||||
|
*/
|
||||||
|
class EventRenderer(private val terminal: CliTerminal) {
|
||||||
|
|
||||||
|
/** Трекает открыт ли сейчас «блок ответа» (после [Event.StartResponse], до [Event.End]). */
|
||||||
|
private var responseOpen = false
|
||||||
|
|
||||||
|
suspend fun render(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is Event.StartReasoning -> {
|
||||||
|
terminal.printSystem("…thinking…")
|
||||||
|
if (responseOpen) {
|
||||||
|
terminal.println()
|
||||||
|
responseOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.StartResponse -> {
|
||||||
|
if (responseOpen) terminal.println()
|
||||||
|
responseOpen = true
|
||||||
|
// Без префикса — текст будет стримиться дальше через AppendText.
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.AppendText -> {
|
||||||
|
terminal.print(event.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.AppendImage -> {
|
||||||
|
terminal.print("[image:${event.mime}:${event.body.size} bytes]")
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.Interrupted -> {
|
||||||
|
if (responseOpen) {
|
||||||
|
terminal.println()
|
||||||
|
terminal.printSystem("[interrupted]")
|
||||||
|
responseOpen = false
|
||||||
|
} else {
|
||||||
|
terminal.printSystem("[interrupted]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.End -> {
|
||||||
|
if (responseOpen) {
|
||||||
|
terminal.println()
|
||||||
|
responseOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.Error -> {
|
||||||
|
terminal.println()
|
||||||
|
terminal.printSystem("[error${event.code?.let { "/$it" } ?: ""}] ${event.message}")
|
||||||
|
if (responseOpen) responseOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// ToolCall/ToolResult — это «структура» диалога, в текстовом стриме
|
||||||
|
// не показываем; в веб-UI будет по-другому.
|
||||||
|
terminal.printSystem("[event:${event::class.simpleName}]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
responseOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа CLI. Поддерживает аргументы командной строки:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* agentik-cli [--server URL] [--id ID] [--no-history] [--help]
|
||||||
|
*
|
||||||
|
* --server URL базовый URL сервера agentik (default $AGENTIK_SERVER или
|
||||||
|
* http://localhost:8080/agentik)
|
||||||
|
* --id ID идентификатор этого клиента (default "cli:$USER")
|
||||||
|
* --no-history не сохранять состояние в ~/.agentik/cli-state.json
|
||||||
|
* --help, -h распечатать usage и выйти
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Без аргументов — стартует REPL.
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
val cfg = parseCliArgs(args)
|
||||||
|
if (cfg == null) {
|
||||||
|
printUsage()
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
AgentikCli(cfg).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация CLI, вычисленная из аргументов + переменных среды.
|
||||||
|
* Доступна из других файлов commonMain (видна как `internal` внутри модуля).
|
||||||
|
*/
|
||||||
|
internal data class CliConfig(
|
||||||
|
val server: String,
|
||||||
|
val id: String,
|
||||||
|
val historyEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseCliArgs(args: Array<String>): CliConfig? {
|
||||||
|
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 resolvedServer = server
|
||||||
|
?: CliPlatform.env("AGENTIK_SERVER")
|
||||||
|
?: "http://localhost:8080/agentik"
|
||||||
|
val resolvedId = id ?: "cli:${CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"}"
|
||||||
|
|
||||||
|
return CliConfig(
|
||||||
|
server = resolvedServer,
|
||||||
|
id = resolvedId,
|
||||||
|
historyEnabled = historyEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printUsage() {
|
||||||
|
val defaultServer = CliPlatform.env("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
|
val defaultUser = CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"
|
||||||
|
println("""
|
||||||
|
agentik-cli — REPL поверх протокола agentik
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
agentik-cli [--server URL] [--id ID] [--no-history]
|
||||||
|
|
||||||
|
Аргументы:
|
||||||
|
--server, -s URL базовый URL (default: $defaultServer)
|
||||||
|
--id ID идентификатор клиента (default: cli:${defaultUser})
|
||||||
|
--no-history не сохранять состояние в ~/.agentik/cli-state.json
|
||||||
|
--help, -h эта справка
|
||||||
|
|
||||||
|
Переменные среды:
|
||||||
|
AGENTIK_SERVER базовый URL агента (используется если --server не задан)
|
||||||
|
HOME для пути ~/.agentik/cli-state.json
|
||||||
|
|
||||||
|
В REPL:
|
||||||
|
/help список slash-команд
|
||||||
|
/new [title] создать диалог (title опционально)
|
||||||
|
/list, /ls список диалогов
|
||||||
|
/switch <id>, /sw <id> переключиться на диалог
|
||||||
|
/rename <title> переименовать текущий диалог
|
||||||
|
/delete [<id>], /rm удалить (по id или текущий)
|
||||||
|
/history, /h последние сообщения текущего диалога
|
||||||
|
/interrupt, /stop прервать текущий ход
|
||||||
|
/pwd показать текущий диалог
|
||||||
|
/exit, /quit выйти (Ctrl-D тоже работает)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние CLI между запусками: последний выбранный диалог и момент последнего
|
||||||
|
* увиденного [Event.date] в его потоке (для корректного `events(after)` после рестарта).
|
||||||
|
*
|
||||||
|
* Доступ к диску инкапсулирован в платформенный [CliPlatform] — commonMain ничего
|
||||||
|
* не знает про `java.io.File`/`NSFileManager`, чтобы KMP-сборка собиралась
|
||||||
|
* под все цели. Файл: `$HOME/.agentik/cli-state.json`.
|
||||||
|
*/
|
||||||
|
internal class SessionRepository internal constructor(
|
||||||
|
private val filePath: String?,
|
||||||
|
private val io: SessionIo,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class State(
|
||||||
|
val conversationId: String,
|
||||||
|
val lastEventAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/** Открывается ленивым чтением. [save] ещё не было — файл может отсутствовать. */
|
||||||
|
private var cached: State? = null
|
||||||
|
|
||||||
|
fun load(): SavedSession? {
|
||||||
|
val path = filePath ?: return null
|
||||||
|
val raw = io.readAll(path) ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val state = json.decodeFromString(State.serializer(), raw)
|
||||||
|
cached = state
|
||||||
|
SavedSession(
|
||||||
|
conversationId = state.conversationId,
|
||||||
|
lastEventAt = Instant.parse(state.lastEventAt),
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(conversationId: String, lastEventAt: Instant) {
|
||||||
|
val path = filePath ?: return
|
||||||
|
val state = State(
|
||||||
|
conversationId = conversationId,
|
||||||
|
lastEventAt = lastEventAt.toString(),
|
||||||
|
)
|
||||||
|
cached = state
|
||||||
|
val body = json.encodeToString(State.serializer(), state)
|
||||||
|
io.writeAtomic(path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
val path = filePath ?: return
|
||||||
|
io.delete(path)
|
||||||
|
cached = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
// для совместимости с будущим in-memory state; пока no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class SavedSession(
|
||||||
|
val conversationId: String,
|
||||||
|
val lastEventAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный платформо-зависимый IO-интерфейс для одного файла. Реализации
|
||||||
|
* в jvmMain (`java.io.File` + atomic `tmp → rename`) и в nativeMain (пока no-op-stub).
|
||||||
|
*
|
||||||
|
* public, потому что его возвращает public [CliPlatform.sessionIo].
|
||||||
|
*/
|
||||||
|
interface SessionIo {
|
||||||
|
fun readAll(path: String): String?
|
||||||
|
fun writeAtomic(path: String, body: String)
|
||||||
|
fun delete(path: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slash-команды REPL'а. Первая буква `/` не хранится — парсер уже её отрезал.
|
||||||
|
*
|
||||||
|
* Свободный ввод (без `/` в начале) — это сообщение пользователя агенту в
|
||||||
|
* текущий диалог и НЕ разбирается в [parse].
|
||||||
|
*/
|
||||||
|
sealed interface SlashCommand {
|
||||||
|
data object Help : SlashCommand
|
||||||
|
data object Exit : SlashCommand
|
||||||
|
data object Quit : SlashCommand // синоним Exit
|
||||||
|
|
||||||
|
/** Создать новый диалог; опционально — заголовок. */
|
||||||
|
data class New(val title: String?) : SlashCommand
|
||||||
|
|
||||||
|
/** Список диалогов (cold flow — печатаем по мере прихода страниц). */
|
||||||
|
data object List : SlashCommand
|
||||||
|
|
||||||
|
/** Подключиться к существующему диалогу по id. */
|
||||||
|
data class Switch(val id: String) : SlashCommand
|
||||||
|
|
||||||
|
/** Переименовать текущий диалог. */
|
||||||
|
data class Rename(val title: String) : SlashCommand
|
||||||
|
|
||||||
|
/** Удалить диалог (по id или текущий). */
|
||||||
|
data class Delete(val id: String?) : SlashCommand
|
||||||
|
|
||||||
|
/** Прервать текущий ход. no-op если хода нет. */
|
||||||
|
data object Interrupt : SlashCommand
|
||||||
|
|
||||||
|
/** Показать последние сообщения текущего диалога (cold flow). */
|
||||||
|
data object History : SlashCommand
|
||||||
|
|
||||||
|
/** Показать информацию о текущем диалоге. */
|
||||||
|
data object Pwd : SlashCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит строку (без ведущего `/`) в [SlashCommand] либо возвращает [Result.Failure]
|
||||||
|
* с сообщением об ошибке.
|
||||||
|
*
|
||||||
|
* Команды нечувствительны к регистру (команда `/LIST` == `/list`).
|
||||||
|
*/
|
||||||
|
fun parseSlash(input: String): ParseResult {
|
||||||
|
val s = input.trim()
|
||||||
|
if (s.isEmpty()) return ParseResult.Failure("пустая команда (введите /help)")
|
||||||
|
|
||||||
|
// Разбиваем на команду и её аргументы. Поддерживаем склейку: /new foo bar → new "foo bar"
|
||||||
|
val firstSpace = s.indexOfAny(charArrayOf(' ', '\t'))
|
||||||
|
val cmd = if (firstSpace < 0) s else s.substring(0, firstSpace)
|
||||||
|
val rest = if (firstSpace < 0) "" else s.substring(firstSpace + 1).trim()
|
||||||
|
val args = if (rest.isEmpty()) emptyList() else rest.split(' ').filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val command: SlashCommand? = when (cmd.lowercase()) {
|
||||||
|
"help", "?" -> SlashCommand.Help
|
||||||
|
"exit" -> SlashCommand.Exit
|
||||||
|
"quit", "q" -> SlashCommand.Quit
|
||||||
|
"new" -> SlashCommand.New(rest.takeIf { it.isNotEmpty() })
|
||||||
|
"list", "ls" -> SlashCommand.List
|
||||||
|
"switch", "sw", "cd" -> args.firstOrNull()?.let { SlashCommand.Switch(it) }
|
||||||
|
"rename", "mv", "title" -> rest.takeIf { it.isNotEmpty() }?.let { SlashCommand.Rename(it) }
|
||||||
|
"delete", "rm" -> SlashCommand.Delete(args.firstOrNull())
|
||||||
|
"interrupt", "stop", "cancel" -> SlashCommand.Interrupt
|
||||||
|
"history", "hist", "h" -> SlashCommand.History
|
||||||
|
"pwd", "where" -> SlashCommand.Pwd
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (command != null) return ParseResult.Success(command)
|
||||||
|
|
||||||
|
// Не нашли команду: либо неизвестная, либо не хватает аргумента.
|
||||||
|
val cmdLower = cmd.lowercase()
|
||||||
|
return when (cmdLower) {
|
||||||
|
"switch", "sw", "cd" -> ParseResult.Failure("укажите id диалога: /switch <id>")
|
||||||
|
"rename", "mv", "title" -> ParseResult.Failure("укажите заголовок: /rename <title>")
|
||||||
|
else -> ParseResult.Failure("неизвестная команда: /$cmd (введите /help)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ParseResult {
|
||||||
|
data class Success(val command: SlashCommand) : ParseResult
|
||||||
|
data class Failure(val message: String) : ParseResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удобный helper для тестов и общего кода. */
|
||||||
|
fun parseSlashOrNull(input: String): SlashCommand? =
|
||||||
|
when (val r = parseSlash(input)) {
|
||||||
|
is ParseResult.Success -> r.command
|
||||||
|
is ParseResult.Failure -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подменяем [CliTerminal] простой in-memory реализацией и проверяем,
|
||||||
|
* что события рендерятся в правильном формате.
|
||||||
|
*/
|
||||||
|
class EventRendererTest {
|
||||||
|
|
||||||
|
private class FakeTerminal : CliTerminal {
|
||||||
|
override val prompt: String = ">"
|
||||||
|
val out = StringBuilder()
|
||||||
|
override suspend fun readLine(): String? = null
|
||||||
|
override suspend fun println(text: String) { out.appendLine(text) }
|
||||||
|
override suspend fun print(text: String) { out.append(text) }
|
||||||
|
override suspend fun printSystem(text: String) { out.appendLine("· $text") }
|
||||||
|
override fun close() {}
|
||||||
|
fun text() = out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `simple response stream`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
|
||||||
|
r.render(Event.AppendText(Instant.DISTANT_PAST, "Привет"))
|
||||||
|
r.render(Event.AppendText(Instant.DISTANT_PAST, ", мир!"))
|
||||||
|
r.render(Event.End(Instant.DISTANT_PAST))
|
||||||
|
|
||||||
|
// StartResponse открывает блок, AppendText без \n, End закрывает \n
|
||||||
|
val text = t.text()
|
||||||
|
assertTrue(text.contains("Привет, мир!"), "got: $text")
|
||||||
|
// после End должен быть перевод строки
|
||||||
|
assertTrue(text.endsWith("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupted closes block`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
|
||||||
|
r.render(Event.AppendText(Instant.DISTANT_PAST, "Частично"))
|
||||||
|
r.render(Event.Interrupted(Instant.DISTANT_PAST))
|
||||||
|
val text = t.text()
|
||||||
|
assertTrue(text.contains("Частично"))
|
||||||
|
assertTrue(text.contains("· [interrupted]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `error before response`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.Error(Instant.DISTANT_PAST, message = "что-то сломалось", code = "500"))
|
||||||
|
val text = t.text()
|
||||||
|
assertTrue(text.contains("· [error/500] что-то сломалось"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `start_reasoning is printed as system line`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartReasoning(Instant.DISTANT_PAST))
|
||||||
|
assertTrue(t.text().contains("· …thinking…"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `image append renders placeholder`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.IMAGE))
|
||||||
|
r.render(Event.AppendImage(Instant.DISTANT_PAST, body = ByteArray(64), mime = "image/png"))
|
||||||
|
r.render(Event.End(Instant.DISTANT_PAST))
|
||||||
|
assertTrue(t.text().contains("[image:image/png:64 bytes]"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SlashCommandTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `help is parsed`() {
|
||||||
|
assertIs<SlashCommand.Help>(parseSlashOrNull("help"))
|
||||||
|
assertIs<SlashCommand.Help>(parseSlashOrNull("?"))
|
||||||
|
assertIs<SlashCommand.Help>(parseSlashOrNull("HELP"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exit and quit alias`() {
|
||||||
|
assertIs<SlashCommand.Exit>(parseSlashOrNull("exit"))
|
||||||
|
assertIs<SlashCommand.Quit>(parseSlashOrNull("q"))
|
||||||
|
assertIs<SlashCommand.Quit>(parseSlashOrNull("Quit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new without title`() {
|
||||||
|
assertIs<SlashCommand.New>(parseSlashOrNull("new")).let {
|
||||||
|
assertEquals(null, it.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new with multi-word title`() {
|
||||||
|
val cmd = parseSlashOrNull("new my cool chat")
|
||||||
|
assertIs<SlashCommand.New>(cmd)
|
||||||
|
assertEquals("my cool chat", cmd.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switch requires id`() {
|
||||||
|
val r = parseSlash("sw")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switch with id`() {
|
||||||
|
val cmd = parseSlashOrNull("switch abc123")
|
||||||
|
assertIs<SlashCommand.Switch>(cmd)
|
||||||
|
assertEquals("abc123", cmd.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename requires title`() {
|
||||||
|
val r = parseSlash("rename")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
// А "rename " (с пробелом, но без слов после) — это уже успех с пустым title?
|
||||||
|
// У нас: rest = "" → takeIf { it.isNotEmpty() } → null → Failure. ОК.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename with title`() {
|
||||||
|
val cmd = parseSlashOrNull("rename my new title ")
|
||||||
|
assertIs<SlashCommand.Rename>(cmd)
|
||||||
|
assertEquals("my new title", cmd.title) // trim() делает своё
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete may have id or not`() {
|
||||||
|
assertIs<SlashCommand.Delete>(parseSlashOrNull("rm")).let {
|
||||||
|
assertEquals(null, it.id)
|
||||||
|
}
|
||||||
|
assertIs<SlashCommand.Delete>(parseSlashOrNull("delete abc")).let {
|
||||||
|
assertEquals("abc", it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown command fails`() {
|
||||||
|
val r = parseSlash("foobar")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty command fails`() {
|
||||||
|
val r = parseSlash("")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `command is case insensitive`() {
|
||||||
|
assertIs<SlashCommand.List>(parseSlashOrNull("LIST"))
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("STOP"))
|
||||||
|
assertIs<SlashCommand.Pwd>(parseSlashOrNull("PWD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupt synonyms`() {
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("interrupt"))
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("stop"))
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("cancel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jline.reader.EndOfFileException
|
||||||
|
import org.jline.reader.LineReader
|
||||||
|
import org.jline.reader.LineReaderBuilder
|
||||||
|
import org.jline.reader.UserInterruptException
|
||||||
|
import org.jline.terminal.TerminalBuilder
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
actual object CliPlatform {
|
||||||
|
actual fun openAgent(baseUrl: String, id: String): Agent =
|
||||||
|
AgentikAgent(id = id, baseUrl = baseUrl)
|
||||||
|
|
||||||
|
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
|
||||||
|
JLineTerminal(historyFile = historyFile, prompt = prompt)
|
||||||
|
|
||||||
|
actual fun homeDir(): String? =
|
||||||
|
System.getenv("HOME") ?: System.getenv("USERPROFILE")
|
||||||
|
|
||||||
|
actual fun env(key: String): String? = System.getenv(key)
|
||||||
|
|
||||||
|
actual fun sessionIo(): SessionIo = JvmSessionIo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [SessionIo] поверх `java.io.File` + atomic `tmp → rename`.
|
||||||
|
* tmp-файл пишется в той же директории, что и целевой, чтобы rename
|
||||||
|
* был атомарным в рамках одного раздела (POSIX rename(2) и Windows
|
||||||
|
* MoveFileEx — атомарны внутри одного тома).
|
||||||
|
*/
|
||||||
|
private object JvmSessionIo : SessionIo {
|
||||||
|
override fun readAll(path: String): String? {
|
||||||
|
val f = File(path)
|
||||||
|
if (!f.exists()) return null
|
||||||
|
return runCatching { f.readText() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeAtomic(path: String, body: String) {
|
||||||
|
val target = File(path)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val tmp = File(path + ".tmp")
|
||||||
|
tmp.writeText(body)
|
||||||
|
if (!tmp.renameTo(target)) {
|
||||||
|
// fallback: Windows-специфика — renameTo может не перезаписать существующий.
|
||||||
|
runCatching { Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) }
|
||||||
|
.getOrElse { target.writeText(tmp.readText()); tmp.delete() }
|
||||||
|
}
|
||||||
|
} override fun delete(path: String) {
|
||||||
|
runCatching { File(path).delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [CliTerminal] поверх JLine ([LineReader]).
|
||||||
|
*
|
||||||
|
* JLine-3 API:
|
||||||
|
* - [TerminalBuilder.builder().system(true).build()] — открыть системный TTY.
|
||||||
|
* - [LineReader] поверх Terminal — readline-редактор (стрелки, history, Ctrl-A/E).
|
||||||
|
* - [LineReader.readLine(prompt)] — suspend-free, блокирующий IO; мы оборачиваем
|
||||||
|
* в [withContext] [Dispatchers.IO], чтобы не держать event-loop.
|
||||||
|
* - [DefaultHistory] (org.jline.reader.history.DefaultHistory) + история из файла.
|
||||||
|
*/
|
||||||
|
private class JLineTerminal(
|
||||||
|
historyFile: String?,
|
||||||
|
override val prompt: String,
|
||||||
|
) : CliTerminal {
|
||||||
|
|
||||||
|
private val terminal = TerminalBuilder.builder()
|
||||||
|
.system(true)
|
||||||
|
.jna(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val historyImpl: org.jline.reader.History? = run {
|
||||||
|
if (historyFile == null) null else try {
|
||||||
|
val history = org.jline.reader.impl.history.DefaultHistory()
|
||||||
|
val histFile = File(historyFile)
|
||||||
|
histFile.parentFile?.mkdirs()
|
||||||
|
history.load()
|
||||||
|
if (histFile.exists()) {
|
||||||
|
history.append(histFile.toPath(), true)
|
||||||
|
}
|
||||||
|
history
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val reader: LineReader = LineReaderBuilder.builder()
|
||||||
|
.terminal(terminal)
|
||||||
|
.apply { if (historyImpl != null) history(historyImpl) }
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val historyFilePath: java.nio.file.Path? =
|
||||||
|
historyFile?.let { File(it).toPath() }
|
||||||
|
|
||||||
|
override suspend fun readLine(): String? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val line = reader.readLine(prompt)
|
||||||
|
// Сохраняем history при каждой строке — дешево, и при Ctrl-D / Ctrl-C
|
||||||
|
// ничего не теряется.
|
||||||
|
flushHistory()
|
||||||
|
line
|
||||||
|
} catch (_: UserInterruptException) {
|
||||||
|
// Ctrl-C: трактуем как «всё, выходим», как и EOF.
|
||||||
|
flushHistory()
|
||||||
|
null
|
||||||
|
} catch (_: EndOfFileException) {
|
||||||
|
// Ctrl-D на пустой строке.
|
||||||
|
flushHistory()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun println(text: String): Unit = withContext(Dispatchers.IO) {
|
||||||
|
terminal.writer().println(text)
|
||||||
|
terminal.writer().flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun print(text: String): Unit = withContext(Dispatchers.IO) {
|
||||||
|
terminal.writer().print(text)
|
||||||
|
terminal.writer().flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun printSystem(text: String): Unit = withContext(Dispatchers.IO) {
|
||||||
|
terminal.writer().println("· $text")
|
||||||
|
terminal.writer().flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushHistory() {
|
||||||
|
val hf = historyFilePath ?: return
|
||||||
|
val h = historyImpl ?: return
|
||||||
|
runCatching {
|
||||||
|
h.save()
|
||||||
|
if (!h.isEmpty) {
|
||||||
|
// читаем из .tmp и дописываем
|
||||||
|
h.append(hf, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { flushHistory() }
|
||||||
|
runCatching { terminal.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционный тест на реальном временном файле. Только JVM: использует
|
||||||
|
* [java.io.File] для IO-интерфейса. На native-таргетах тест не собирается —
|
||||||
|
* TODO: переписать на kotlinx-io Files и перенести в commonTest.
|
||||||
|
*/
|
||||||
|
class SessionRepositoryTest {
|
||||||
|
|
||||||
|
private lateinit var tmp: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
tmp = File.createTempFile("agentik-cli-state", ".json")
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
if (tmp.exists()) tmp.delete()
|
||||||
|
File(tmp.path + ".tmp").delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load returns null when file missing`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
assertNull(repo.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save then load roundtrip`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
val savedAt = Instant.parse("2026-09-16T10:00:00Z")
|
||||||
|
repo.save(conversationId = "abcd-1234", lastEventAt = savedAt)
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
val repo2 = SessionRepository(tmp.path, JvmIo)
|
||||||
|
val restored = repo2.load()
|
||||||
|
assertNotNull(restored)
|
||||||
|
assertEquals("abcd-1234", restored.conversationId)
|
||||||
|
assertEquals(savedAt, restored.lastEventAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save overwrites previous state`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
repo.save("conv-1", Instant.parse("2026-09-16T10:00:00Z"))
|
||||||
|
repo.save("conv-2", Instant.parse("2026-09-16T11:00:00Z"))
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
val restored = SessionRepository(tmp.path, JvmIo).load()
|
||||||
|
assertNotNull(restored)
|
||||||
|
assertEquals("conv-2", restored.conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null filepath means no-op`() {
|
||||||
|
val repo = SessionRepository(null, JvmIo)
|
||||||
|
repo.save("conv-X", Instant.parse("2026-09-16T10:00:00Z"))
|
||||||
|
// Не должно ни читать, ни писать.
|
||||||
|
assertNull(repo.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear deletes file`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
repo.save("conv-Z", Instant.parse("2026-09-16T10:00:00Z"))
|
||||||
|
repo.close()
|
||||||
|
assertTrue(tmp.exists())
|
||||||
|
|
||||||
|
val repo2 = SessionRepository(tmp.path, JvmIo)
|
||||||
|
repo2.clear()
|
||||||
|
assertTrue(!tmp.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `corrupt json is ignored (does not throw)`() {
|
||||||
|
File(tmp.path).writeText("this is not json")
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
assertNull(repo.load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JVM-only helper: реализация [SessionIo] поверх `java.io.File` для теста.
|
||||||
|
// В продакшен-коде на jvmMain ровно такая же логика.
|
||||||
|
private object JvmIo : SessionIo {
|
||||||
|
override fun readAll(path: String): String? {
|
||||||
|
val f = File(path); if (!f.exists()) return null
|
||||||
|
return runCatching { f.readText() }.getOrNull()
|
||||||
|
}
|
||||||
|
override fun writeAtomic(path: String, body: String) {
|
||||||
|
val target = File(path); target.parentFile?.mkdirs()
|
||||||
|
val tmp = File(path + ".tmp")
|
||||||
|
tmp.writeText(body)
|
||||||
|
if (!tmp.renameTo(target)) target.writeText(tmp.readText()).also { tmp.delete() }
|
||||||
|
}
|
||||||
|
override fun delete(path: String) { File(path).delete() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформо-зависимая реализация для native-целей.
|
||||||
|
*
|
||||||
|
* Текущий статус: stub. native HTTP требует подключения ktor-client-core +
|
||||||
|
* платформенных engine'ов (ktor-client-darwin для Apple, ktor-client-curl для
|
||||||
|
* linux/mingw, ktor-client-okhttp для Android в перспективе) и переиспользования
|
||||||
|
* уже существующего `:client` SSE-парсера. Native readline требует termios
|
||||||
|
* через `kotlinx.cinterop` — добавим, когда дойдут руки.
|
||||||
|
*
|
||||||
|
* Пока запустить агента из native-бинаря CLI нельзя, но проект компилируется
|
||||||
|
* под все 8 KMP-целей — структурная готовность соблюдена.
|
||||||
|
*/
|
||||||
|
actual object CliPlatform {
|
||||||
|
actual fun openAgent(baseUrl: String, id: String): Agent =
|
||||||
|
error("agentik-cli native target is not implemented yet (baseUrl=$baseUrl)")
|
||||||
|
|
||||||
|
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
|
||||||
|
error("agentik-cli native target is not implemented yet (prompt=$prompt)")
|
||||||
|
|
||||||
|
actual fun homeDir(): String? = null
|
||||||
|
|
||||||
|
actual fun env(key: String): String? = null
|
||||||
|
|
||||||
|
actual fun sessionIo(): SessionIo = NoopSessionIo
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Минимальный no-op-IO для native-целей пока не подключён реальный движок. */
|
||||||
|
private object NoopSessionIo : SessionIo {
|
||||||
|
override fun readAll(path: String): String? = null
|
||||||
|
override fun writeAtomic(path: String, body: String) {}
|
||||||
|
override fun delete(path: String) {}
|
||||||
|
}
|
||||||
@@ -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,87 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(project(":client"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,154 @@
|
|||||||
|
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.KeyEvent
|
||||||
|
import com.jakewharton.mosaic.layout.drawBehind
|
||||||
|
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Box
|
||||||
|
import com.jakewharton.mosaic.ui.Column
|
||||||
|
import com.jakewharton.mosaic.ui.Row
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая Compose-композиция TUI.
|
||||||
|
*
|
||||||
|
* Layout (минимальный):
|
||||||
|
* ```
|
||||||
|
* ┌─────────────────────────────────────────────────────────┐
|
||||||
|
* │ HEADER: agentik · id · conv-id · focus=… │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ HISTORY (весь актуальный диалог) │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ INPUT LINE: > text| │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||||
|
* └─────────────────────────────────────────────────────────┘
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@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(state, showHelp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showHelp) HelpOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HistoryPanel(state: AppState) {
|
||||||
|
val messages by state.messages.collectAsState()
|
||||||
|
val scroll by state.historyScroll.collectAsState()
|
||||||
|
val rendered = if (messages.isEmpty()) {
|
||||||
|
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||||
|
} else {
|
||||||
|
messages.joinToString("") { renderMessage(it) }
|
||||||
|
}
|
||||||
|
Text(value = rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private 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 */ }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Footer(state: AppState, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private 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,160 @@
|
|||||||
|
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) {
|
||||||
|
/** Зона фокуса: 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
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
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,97 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа TUI-клиента agentik.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Без аргументов — стартует Compose-Mosaic UI.
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
val cfg = parseCliArgs(args) ?: run {
|
||||||
|
printUsage()
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
TuiApp(cfg).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация 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?
|
||||||
|
|
||||||
|
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 эта справка
|
||||||
|
|
||||||
|
В UI:
|
||||||
|
Tab / Shift-Tab переключить фокус между историей и вводом
|
||||||
|
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
||||||
|
← / → двинуть курсор в инпуте
|
||||||
|
Enter отправить сообщение
|
||||||
|
Ctrl-C / Ctrl-D выйти
|
||||||
|
F1 показать подсказки по горячим клавишам
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import com.jakewharton.mosaic.runMosaicBlocking
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая точка запуска UI. Стартует Mosaic-рантайм и ждёт завершения приложения.
|
||||||
|
*
|
||||||
|
* По дизайну — singleton: все остальные модули (UI, бэкенд-корутины) живут внутри
|
||||||
|
* одной Compose-композиции и пользуются её [CoroutineScope].
|
||||||
|
*
|
||||||
|
* Реальный бэкенд (TuiBackend) подключается в следующем коммите: сейчас
|
||||||
|
* стартует на пустом [Agent]-заглушке для smoke-теста.
|
||||||
|
*/
|
||||||
|
internal class TuiApp(private val config: TuiConfig) {
|
||||||
|
fun run() {
|
||||||
|
runMosaicBlocking {
|
||||||
|
val state = remember { AppState(config) }
|
||||||
|
App(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформенная фабрика [Agent]. JVM-only пока: native не подключали ktor-движки.
|
||||||
|
*/
|
||||||
|
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [TuiApp.createAgent] для JVM — обычный ktor-cio через `:client`.
|
||||||
|
*/
|
||||||
|
internal fun jvmCreateAgent(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 fun nativeCreateAgent(baseUrl: String, id: String): Agent =
|
||||||
|
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
|
||||||
+132
-1
@@ -2,7 +2,138 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
// maven-publish — стандартный плагин Gradle, объявлен apply'ем в subprojects ниже.
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "pw.binom.agentik"
|
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 ("version=0.1.0"). Без версии 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("0.1.0")
|
||||||
|
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-клиент (desktop, без iOS) с клавиатурной навигацией без ':'-префиксов.",
|
||||||
|
"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-tui` — Compose-for-Mosaic клиент.
|
||||||
|
- Любой внешний 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.
|
||||||
@@ -20,5 +20,6 @@ dependencies {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import io.ktor.http.HttpStatusCode
|
|||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
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.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.proto.Event
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,10 +60,10 @@ internal class ConversationClient(
|
|||||||
snapshot = updated
|
snapshot = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun send(content: List<Content>) {
|
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||||
httpClient.post("$convUrl/messages") {
|
httpClient.post("$convUrl/messages") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(content)
|
setBody(SendPayload(content, context))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,3 +94,9 @@ internal class ConversationClient(
|
|||||||
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
|
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SendPayload(
|
||||||
|
val content: List<Content>,
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import kotlin.time.Instant
|
|||||||
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
||||||
*/
|
*/
|
||||||
internal object InstantSerializer : KSerializer<Instant> {
|
internal object InstantSerializer : KSerializer<Instant> {
|
||||||
|
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
|
||||||
|
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
|
||||||
|
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
|
||||||
override val descriptor: SerialDescriptor =
|
override val descriptor: SerialDescriptor =
|
||||||
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
|
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: Instant) =
|
override fun serialize(encoder: Encoder, value: Instant) =
|
||||||
encoder.encodeString(value.toString())
|
encoder.encodeString(value.toString())
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
|
# Default version for local builds; overridden by `-Pversion=<tag>` from CI/CD.
|
||||||
|
version=0.1.0
|
||||||
|
|
||||||
|
# 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,29 @@
|
|||||||
kotlin = "2.4.20"
|
kotlin = "2.4.20"
|
||||||
kotlinx-serialization = "1.11.0"
|
kotlinx-serialization = "1.11.0"
|
||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
|
kotlinx-io = "0.8.0"
|
||||||
ktor = "3.1.3"
|
ktor = "3.1.3"
|
||||||
a2a = "1.0.0-SNAPSHOT"
|
a2a = "1.0.0-SNAPSHOT"
|
||||||
kaml = "0.104.0"
|
kaml = "0.104.0"
|
||||||
litert = "7"
|
litert = "8"
|
||||||
sqldelight = "2.3.2"
|
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"
|
||||||
|
jline = "3.30.0"
|
||||||
|
mosaic = "0.18.0"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", 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" }
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
|
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
|
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
|
||||||
@@ -25,7 +37,7 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
|
|||||||
|
|
||||||
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
|
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
|
||||||
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
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" }
|
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
|
||||||
|
|
||||||
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
||||||
@@ -49,9 +61,40 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
|||||||
# --- Model Context Protocol (MCP) ---
|
# --- Model Context Protocol (MCP) ---
|
||||||
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
||||||
|
|
||||||
|
# --- CLI: JLine (readline для JVM-таргета) ---
|
||||||
|
jline = { module = "org.jline:jline", version.ref = "jline" }
|
||||||
|
|
||||||
|
# --- 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" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
|
|
||||||
# --- commons ---
|
# --- commons ---
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
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-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-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,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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пара (пользователь, ассистент) для review-loop'а.
|
||||||
|
*
|
||||||
|
* @property conversationId id диалога, из которого взят ход. Нужен, чтобы
|
||||||
|
* потом привязать появившиеся заметки к диалогу
|
||||||
|
* (глобальные заметки идут с conversationId=null).
|
||||||
|
*/
|
||||||
|
data class ReviewedTurn(
|
||||||
|
val userMessage: String,
|
||||||
|
val assistantMessage: String,
|
||||||
|
val conversationId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пара (user + assistant) с временной меткой для пакетного review-loop'а.
|
||||||
|
* Используется при compaction'е working memory — когда ходы уходят в summary,
|
||||||
|
* у нас последний шанс вытащить из них факты и положить в долговременную память.
|
||||||
|
*/
|
||||||
|
data class ConversationTurn(
|
||||||
|
val userMessage: String,
|
||||||
|
val assistantMessage: String,
|
||||||
|
val createdAt: kotlin.time.Instant? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кандидат на новую заметку, предложенный review-loop'ом. У `id` нет —
|
||||||
|
* бэкенд назначает при upsert.
|
||||||
|
*/
|
||||||
|
data class NewMemoryNote(
|
||||||
|
val category: MemoryCategory,
|
||||||
|
val content: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление существующей заметки (например, исправление формулировки).
|
||||||
|
*/
|
||||||
|
data class MemoryUpdate(
|
||||||
|
val id: String,
|
||||||
|
val newContent: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вердикт review-loop'а по одному ходу: что сохранить, что обновить, что удалить.
|
||||||
|
*/
|
||||||
|
data class MemoryReviewDecision(
|
||||||
|
val toSave: List<NewMemoryNote> = emptyList(),
|
||||||
|
val toUpdate: List<MemoryUpdate> = emptyList(),
|
||||||
|
val toDelete: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Анализирует завершённый ход и возвращает вердикт — что должно попасть в
|
||||||
|
* долговременную память (или наоборот — удалиться).
|
||||||
|
*
|
||||||
|
* Реализации:
|
||||||
|
* - `:memory-md` — простая эвристика по ключевым словам (user/preference markers).
|
||||||
|
* - `:standalone` (позже) — один-shot LLM-вызов с whitelist-тулсетом.
|
||||||
|
*/
|
||||||
|
interface MemoryReviewer {
|
||||||
|
/** Review одного завершённого хода (вызывается после каждого assistant-ответа). */
|
||||||
|
suspend fun review(turn: ReviewedTurn): MemoryReviewDecision
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review пачки ходов перед compaction'ом working memory. Зовётся агентом
|
||||||
|
* за один раз перед удалением старых ходов — последний шанс вытащить из них
|
||||||
|
* факты до того, как они схлопнутся в summary.
|
||||||
|
*
|
||||||
|
* Дефолтная реализация — наивная: скармливает каждый ход в [review] по
|
||||||
|
* отдельности. Реализации с настоящей LLM-семантикой могут посмотреть на
|
||||||
|
* ходы пакетом и принимать решения с учётом контекста (например, не дублировать
|
||||||
|
* уже сохранённые факты).
|
||||||
|
*/
|
||||||
|
suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
|
||||||
|
val aggregated = MemoryReviewDecision(
|
||||||
|
toSave = turns.flatMap { review(ReviewedTurn(userMessage = it.userMessage, assistantMessage = it.assistantMessage)).toSave },
|
||||||
|
)
|
||||||
|
return aggregated
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос на семантический (или, в MD-бэкенде — ключевой) поиск по памяти.
|
||||||
|
*
|
||||||
|
* @property query текст запроса (обычно — последнее сообщение пользователя).
|
||||||
|
* @property topK максимум возвращаемых результатов.
|
||||||
|
* @property category фильтр по категории или null для всех.
|
||||||
|
* @property conversationId фильтр по диалогу: null = глобальная память,
|
||||||
|
* конкретный id = только факты этого диалога,
|
||||||
|
* особое значение [""] НЕ поддерживается — нужен явный диалог
|
||||||
|
* или null.
|
||||||
|
*/
|
||||||
|
data class MemorySearchQuery(
|
||||||
|
val query: String,
|
||||||
|
val topK: Int = 10,
|
||||||
|
val category: MemoryCategory? = null,
|
||||||
|
val conversationId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат поиска с оценкой релевантности. Шкала `score` бэкенд-специфична
|
||||||
|
* (для MD — overlap/total; для векторного — косинусная близость). Семантика — больше = лучше.
|
||||||
|
*/
|
||||||
|
data class MemorySearchResult(
|
||||||
|
val note: MemoryNote,
|
||||||
|
val score: Float,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Канал, через который заметка попала в память.
|
||||||
|
*
|
||||||
|
* - [AGENT_SAVE] — агент сам решил сохранить факт (явный вызов `memory_save` тулом).
|
||||||
|
* - [USER_EXPLICIT] — пользователь попросил сохранить факт.
|
||||||
|
* - [AUTO_REVIEW] — фоновый review-loop после хода (см. `MemoryReviewer`).
|
||||||
|
*/
|
||||||
|
enum class MemorySource(val id: String) {
|
||||||
|
AGENT_SAVE("agent_save"),
|
||||||
|
USER_EXPLICIT("user_explicit"),
|
||||||
|
AUTO_REVIEW("auto_review");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromId(id: String): MemorySource =
|
||||||
|
entries.firstOrNull { it.id == id }
|
||||||
|
?: throw IllegalArgumentException("unknown memory source: $id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Событие мутации памяти для подписчиков (используется review-loop'ом и UI).
|
||||||
|
*/
|
||||||
|
sealed interface MemoryStoreEvent {
|
||||||
|
data class Upserted(val note: MemoryNote) : MemoryStoreEvent
|
||||||
|
data class Deleted(val id: String) : MemoryStoreEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бэкенд-независимое хранилище долговременной памяти агента.
|
||||||
|
*
|
||||||
|
* Контракт:
|
||||||
|
* - [upsert] заменяет запись по `id` либо добавляет новую.
|
||||||
|
* - [get] / [list] / [search] — синхронные по id, листаются с пагинацией, поиск скорируется бэкендом.
|
||||||
|
* - [delete] удаляет по id; возвращает true если запись была.
|
||||||
|
* - [markUsed] бампит `lastUsedAt` и `useCount` — вызывается на каждом выдавании в prefetch.
|
||||||
|
* - [close] идемпотентен; после него любые методы бросают.
|
||||||
|
* - [events] опциональный стрим мутаций; бэкенды без поддержки возвращают [emptyFlow].
|
||||||
|
*
|
||||||
|
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных вызовов.
|
||||||
|
*/
|
||||||
|
interface MemoryStore : AutoCloseable {
|
||||||
|
suspend fun upsert(note: MemoryNote)
|
||||||
|
suspend fun get(id: String): MemoryNote?
|
||||||
|
suspend fun list(
|
||||||
|
category: MemoryCategory? = null,
|
||||||
|
conversationId: String? = null,
|
||||||
|
limit: Int = 100,
|
||||||
|
offset: Int = 0,
|
||||||
|
): List<MemoryNote>
|
||||||
|
|
||||||
|
suspend fun search(query: MemorySearchQuery): List<MemorySearchResult>
|
||||||
|
suspend fun delete(id: String): Boolean
|
||||||
|
suspend fun markUsed(id: String, at: Instant = Clock.System.now())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Архивирует заметки, которые:
|
||||||
|
* - не использовались дольше [maxAge] (считая от `now`);
|
||||||
|
* - имеют `useCount <= [maxUseCount]` (по умолчанию 0, т.е. только никогда
|
||||||
|
* не выданные в prefetch).
|
||||||
|
*
|
||||||
|
* Семантика архивации зависит от бэкенда:
|
||||||
|
* - `:memory-md` — переименовывает -файл с суффиксом `.archived.{ts}`;
|
||||||
|
* - `:memory-vector` — удаляет из SQLite и JVector (там данные
|
||||||
|
* пересоздаются из `agentik.db` при старте).
|
||||||
|
*
|
||||||
|
* Default-имплементация использует [list] + [delete]; бэкенды могут
|
||||||
|
* переопределить для более чистой семантики (особенно MD).
|
||||||
|
*
|
||||||
|
* @return количество архивированных заметок.
|
||||||
|
*/
|
||||||
|
suspend fun archiveStale(
|
||||||
|
maxAge: kotlin.time.Duration,
|
||||||
|
maxUseCount: Int = 0,
|
||||||
|
now: Instant = Clock.System.now(),
|
||||||
|
): Int {
|
||||||
|
val all = list(limit = Int.MAX_VALUE)
|
||||||
|
val cutoff = now - maxAge
|
||||||
|
var archived = 0
|
||||||
|
for (n in all) {
|
||||||
|
if (n.lastUsedAt < cutoff && n.useCount <= maxUseCount) {
|
||||||
|
if (delete(n.id)) archived++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return archived
|
||||||
|
}
|
||||||
|
|
||||||
|
fun events(): Flow<MemoryStoreEvent> = emptyFlow()
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бандл компонентов памяти (store + prefetcher + reviewer), общий интерфейс
|
||||||
|
* для всех бэкендов (`:memory-md`, `:memory-vector`, ...). Используется в
|
||||||
|
* `:standalone` для единообразного DI.
|
||||||
|
*/
|
||||||
|
interface MemorySystem : AutoCloseable {
|
||||||
|
val store: MemoryStore
|
||||||
|
val prefetcher: MemoryPrefetcher
|
||||||
|
val reviewer: MemoryReviewer
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Готовые блоки system-guidance, которые `:standalone` подмешивает в
|
||||||
|
* system-prompt разговора и в review-промпт. Тексты согласованы с
|
||||||
|
* `docs/MEMORY-DESIGN.md` (§6, §10) и описаниями [DefaultMemoryTools].
|
||||||
|
*/
|
||||||
|
object MemorySystemGuidance {
|
||||||
|
/** Блок для основного system-prompt разговора. Объясняет агенту, что у него есть память. */
|
||||||
|
const val MEMORY_GUIDANCE: String = """
|
||||||
|
У тебя есть долговременная память. Доступны тулы:
|
||||||
|
- memory_save(category, content) — сохранить факт, который пригодится в будущем.
|
||||||
|
- memory_read(query, top_k?) — поиск по памяти, когда нужен контекст.
|
||||||
|
- memory_list(category?, limit?) — список фактов (например, для показа пользователю).
|
||||||
|
- memory_delete(id) — удалить факт, когда пользователь просит забыть.
|
||||||
|
|
||||||
|
Категории:
|
||||||
|
- user: о пользователе (кто он, чем занимается, привычки).
|
||||||
|
- world: о проектах, стеке, окружении, людях.
|
||||||
|
- preference: как пользователь хочет, чтобы ты работал.
|
||||||
|
|
||||||
|
НЕ сохраняй: секреты (API-ключи, токены, пароли), одноразовые факты,
|
||||||
|
догадки без подтверждения. Сомневаешься — не сохраняй.
|
||||||
|
"""
|
||||||
|
|
||||||
|
/** Промпт для review-loop'а (см. `MemoryReviewer`). */
|
||||||
|
const val REVIEW_GUIDANCE: String = """
|
||||||
|
Ты — фоновый аналитик. Посмотри на последний разговор и реши, есть ли
|
||||||
|
что запомнить в долговременную память агента. Сохраняй только если:
|
||||||
|
1. Пользователь рассказал о себе: persona, привычки, предпочтения.
|
||||||
|
2. Пользователь рассказал о проекте/окружении: стек, инструменты, сроки.
|
||||||
|
3. Пользователь выразил ожидания к тому, как агент должен работать.
|
||||||
|
|
||||||
|
Если ничего нет — просто ответь "nothing to save" и не вызывай тулы.
|
||||||
|
Если есть — вызови memory_save(category, content) для каждого факта.
|
||||||
|
"""
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Описание одного параметра инструмента памяти. Платформо-агностично —
|
||||||
|
* :standalone оборачивает это в `LiteTool` или backend-специфичные сущности.
|
||||||
|
*/
|
||||||
|
data class MemoryToolParam(
|
||||||
|
val name: String,
|
||||||
|
val type: String,
|
||||||
|
val description: String,
|
||||||
|
val required: Boolean = true,
|
||||||
|
val enumValues: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Описание инструмента, который видит LLM/агент. Имя/описание/параметры —
|
||||||
|
* то, что попадёт в system-prompt или tool-call schema.
|
||||||
|
*/
|
||||||
|
data class MemoryToolDescriptor(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val params: List<MemoryToolParam> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набор инструментов, которые память предоставляет агенту. Конкретный движок
|
||||||
|
* (LiteRT-LM, A2A, IRC) оборачивает эти дескрипторы в свои tool-классы.
|
||||||
|
*/
|
||||||
|
interface MemoryTools {
|
||||||
|
val save: MemoryToolDescriptor
|
||||||
|
val read: MemoryToolDescriptor
|
||||||
|
val list: MemoryToolDescriptor
|
||||||
|
val delete: MemoryToolDescriptor
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaults(): MemoryTools = DefaultMemoryTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дефолтные описания инструментов. Язык — русский, чтобы согласовываться с
|
||||||
|
* [MemorySystemGuidance.MEMORY_GUIDANCE].
|
||||||
|
*/
|
||||||
|
object DefaultMemoryTools : MemoryTools {
|
||||||
|
override val save: MemoryToolDescriptor = MemoryToolDescriptor(
|
||||||
|
name = "memory_save",
|
||||||
|
description = "Сохранить факт в долговременную память агента. Категория — одна из " +
|
||||||
|
"'user' (о пользователе: persona, привычки, предпочтения), " +
|
||||||
|
"'world' (о проектах, стеке, окружении, инструментах), " +
|
||||||
|
"'preference' (как пользователь хочет, чтобы ты работал). " +
|
||||||
|
"НЕ сохраняй секреты (API-ключи, токены, пароли) — pattern-detect и отказывай.",
|
||||||
|
params = listOf(
|
||||||
|
MemoryToolParam(
|
||||||
|
name = "category",
|
||||||
|
type = "string",
|
||||||
|
description = "Категория факта.",
|
||||||
|
required = true,
|
||||||
|
enumValues = listOf("user", "world", "preference"),
|
||||||
|
),
|
||||||
|
MemoryToolParam(
|
||||||
|
name = "content",
|
||||||
|
type = "string",
|
||||||
|
description = "Полный текст факта одним-двумя предложениями.",
|
||||||
|
required = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override val read: MemoryToolDescriptor = MemoryToolDescriptor(
|
||||||
|
name = "memory_read",
|
||||||
|
description = "Поиск по долговременной памяти. Возвращает до top_k заметок, " +
|
||||||
|
"упорядоченных по релевантности (наибольшая первой). " +
|
||||||
|
"Используй перед ответами, требующими контекста о пользователе/проекте.",
|
||||||
|
params = listOf(
|
||||||
|
MemoryToolParam("query", "string", "Поисковый запрос (подстрока или ключевые слова).", true),
|
||||||
|
MemoryToolParam("top_k", "number", "Максимум заметок в ответе (default 10).", false),
|
||||||
|
MemoryToolParam(
|
||||||
|
"category", "string", "Фильтр по категории.", false,
|
||||||
|
enumValues = listOf("user", "world", "preference"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override val list: MemoryToolDescriptor = MemoryToolDescriptor(
|
||||||
|
name = "memory_list",
|
||||||
|
description = "Показать все (или отфильтрованные) заметки памяти. " +
|
||||||
|
"Используй, когда пользователь хочет проверить, что агент помнит.",
|
||||||
|
params = listOf(
|
||||||
|
MemoryToolParam(
|
||||||
|
"category", "string", "Фильтр по категории.", false,
|
||||||
|
enumValues = listOf("user", "world", "preference"),
|
||||||
|
),
|
||||||
|
MemoryToolParam("limit", "number", "Сколько заметок вернуть (default 100).", false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override val delete: MemoryToolDescriptor = MemoryToolDescriptor(
|
||||||
|
name = "memory_delete",
|
||||||
|
description = "Удалить факт из памяти по id. Используй, когда пользователь явно " +
|
||||||
|
"просит забыть что-то.",
|
||||||
|
params = listOf(
|
||||||
|
MemoryToolParam("id", "string", "id заметки (формат mem-<uuid>).", true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# `:memory-md` — файловое хранилище памяти (JVM-only)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Реализация `MemoryStore` поверх обычных файлов в формате [Hermes-style]:
|
||||||
|
|
||||||
|
- `~/.agentik/memory/user.md`
|
||||||
|
- `~/.agentik/memory/world.md`
|
||||||
|
- `~/.agentik/memory/preference.md`
|
||||||
|
|
||||||
|
Каждая секция — это `## <heading>` + содержимое. Ревьювер ищет
|
||||||
|
по заголовкам/словам по ключевому совпадению. Префетчер лениво
|
||||||
|
подгружает секции, наиболее вероятно относящиеся к текущему ходу.
|
||||||
|
|
||||||
|
Решает: простой, прозрачный, git-дружелюбный формат памяти.
|
||||||
|
Пользователь может сам `cat ~/.agentik/memory/world.md` и
|
||||||
|
отредактировать.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` подключает вместо `:memory-vector` когда
|
||||||
|
`AGENTIK_MEMORY_BACKEND=md`.
|
||||||
|
- Дефолт, когда ANN-эмбеддинги слишком дороги или не нужны.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("pw.binom.agentik:memory-md:0.1.0")
|
||||||
|
implementation("pw.binom.agentik:memory-api:0.1.0") // контракт
|
||||||
|
}
|
||||||
|
|
||||||
|
val memory: MemoryStore = openMdMemorySystem(Path("~/.agentik/memory"))
|
||||||
|
memory.save(MemoryCategory.USER, "User prefers tasks short.")
|
||||||
|
memory.query(MemoryCategory.USER, "preferences").forEach(::println)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-memory-md`.
|
||||||
|
|
||||||
|
## Как устроен формат
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# user.md
|
||||||
|
|
||||||
|
## 2026-09-14T10:00:00Z — first session
|
||||||
|
Имя пользователя — Сережа.
|
||||||
|
Любит короткие ответы.
|
||||||
|
|
||||||
|
## 2026-09-15T18:20:00Z — task preferences
|
||||||
|
Не присылать пустые репро.
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая запись начинается с заголовка второго уровня и содержит в
|
||||||
|
первой строке заголовка timestamp и короткое название. Так достигается
|
||||||
|
уникальность и читаемость через `cat`.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :memory-md:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: round-trip save/load, фильтрацию по категории,
|
||||||
|
keyword-search, перезапись, конкурентный доступ (файловая блокировка).
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никаких эмбеддингов. Простой keyword-match (простая substring +
|
||||||
|
TF-IDF-эвристика на русских/латинских словах).
|
||||||
|
- Никакого ANN. Для семантического поиска используйте `:memory-vector`.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Подходит для долговременного "дневникового"
|
||||||
|
хранения.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// Зеркалит набор :proto/:server, чтобы бэкенд памяти собирался на всех
|
||||||
|
// таргетах. Файловый IO идёт через kotlinx-io (SystemFileSystem).
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":memory-api"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.io.core)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetcher поверх [MdMemoryStore]. Делает keyword-поиск (см. [MdMemoryFormat.keywordScore])
|
||||||
|
* и бампит `lastUsedAt`/`useCount` у выданных заметок через [MemoryStore.markUsed].
|
||||||
|
*
|
||||||
|
* Вектор-бэкенд (будущая `:memory-vector`) поставит сюда эмбеддинг-семантику
|
||||||
|
* с тем же контрактом.
|
||||||
|
*/
|
||||||
|
class KeywordMdPrefetcher(private val store: MemoryStore) : MemoryPrefetcher {
|
||||||
|
|
||||||
|
override suspend fun prefetch(
|
||||||
|
query: String,
|
||||||
|
topK: Int,
|
||||||
|
category: MemoryCategory?,
|
||||||
|
): List<MemoryNote> {
|
||||||
|
if (query.isBlank()) return emptyList()
|
||||||
|
val results = store.search(
|
||||||
|
pw.binom.agentik.memory.MemorySearchQuery(
|
||||||
|
query = query,
|
||||||
|
topK = topK,
|
||||||
|
category = category,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val notes = results.map { it.note }
|
||||||
|
for (n in notes) store.markUsed(n.id)
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
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.MemorySystemGuidance
|
||||||
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простая эвристика для review-loop'а: режет user/assistant-текст на предложения
|
||||||
|
* и помечает те, что содержат явные user/preference-маркеры (рус/англ).
|
||||||
|
*
|
||||||
|
* Это намеренно тупее LLM-реализации, которая появится в `:standalone` —
|
||||||
|
* без неё всё равно можно прогонять review-loop и набивать базовую память.
|
||||||
|
* Когда LLM-реализация подключится, она станет дефолтной, а эта останется
|
||||||
|
* для тестов и offline-сценариев.
|
||||||
|
*/
|
||||||
|
class KeywordMdReviewer(
|
||||||
|
private val maxFactsPerTurn: Int = 5,
|
||||||
|
private val maxFactsTotal: Int = 20,
|
||||||
|
) : MemoryReviewer {
|
||||||
|
|
||||||
|
private val userMarkers = listOf(
|
||||||
|
"я ", "я.", "я,", "мой ", "моя ", "моё ", "мои ", "мне ", "у меня ",
|
||||||
|
"i ", "i'm", "i am", "my ", "mine",
|
||||||
|
)
|
||||||
|
private val preferenceMarkers = listOf(
|
||||||
|
"я обычно", "я люблю", "я предпочитаю", "я не люблю", "мне нравится", "мне не нравится",
|
||||||
|
"i usually", "i prefer", "i like", "i don't like", "i hate",
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision {
|
||||||
|
// Эвристика берёт только user-message: ассистентские фразы вида
|
||||||
|
// "I can help with anything" ложно матчат "i " маркер, а настоящие
|
||||||
|
// предпочтения пользователя живут в его сообщениях. LLM-реализация
|
||||||
|
// (в :standalone) смотрит на обе стороны и решает тоньше.
|
||||||
|
val text = turn.userMessage.trim()
|
||||||
|
if (text.isBlank()) return MemoryReviewDecision()
|
||||||
|
return MemoryReviewDecision(toSave = extractFacts(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
|
||||||
|
// Пакетный review: идём по ходам, вытаскиваем факты только из user-сообщений
|
||||||
|
// (assistant-фразы редко несут устойчивые факты о пользователе/мире).
|
||||||
|
// Дубликаты отсеиваются глобальным seen-Set'ом, лимит — maxFactsTotal,
|
||||||
|
// чтобы compaction не превращался в свалку.
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
val toSave = ArrayList<NewMemoryNote>()
|
||||||
|
for (turn in turns) {
|
||||||
|
if (toSave.size >= maxFactsTotal) break
|
||||||
|
val text = turn.userMessage.trim()
|
||||||
|
if (text.isBlank()) continue
|
||||||
|
for (fact in extractFacts(text)) {
|
||||||
|
if (toSave.size >= maxFactsTotal) break
|
||||||
|
val key = fact.content.lowercase()
|
||||||
|
if (!seen.add(key)) continue
|
||||||
|
toSave.add(fact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MemoryReviewDecision(toSave = toSave)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractFacts(text: String): List<NewMemoryNote> {
|
||||||
|
val sentences = text.splitToSentences()
|
||||||
|
val result = ArrayList<NewMemoryNote>()
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
for (s in sentences) {
|
||||||
|
if (result.size >= maxFactsPerTurn) break
|
||||||
|
val trimmed = s.trim()
|
||||||
|
if (trimmed.length < 6) continue
|
||||||
|
val lc = trimmed.lowercase()
|
||||||
|
val category = when {
|
||||||
|
preferenceMarkers.any { lc.contains(it) } -> MemoryCategory.PREFERENCE
|
||||||
|
userMarkers.any { lc.startsWith(it) || lc.contains(" $it") } -> MemoryCategory.USER
|
||||||
|
else -> null
|
||||||
|
} ?: continue
|
||||||
|
val dedupeKey = trimmed.lowercase()
|
||||||
|
if (!seen.add(dedupeKey)) continue
|
||||||
|
result.add(NewMemoryNote(category, trimmed))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.splitToSentences(): List<String> =
|
||||||
|
split(Regex("(?<=[.!?\\n])\\s+")).filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чистый парсер/сериализатор формата §-файлов памяти.
|
||||||
|
*
|
||||||
|
* Формат одного файла (например USER.md):
|
||||||
|
* ```
|
||||||
|
* § id=mem-xxx created=2026-09-14T10:00:00Z last_used=2026-09-14T10:00:00Z uses=0 source=agent_save
|
||||||
|
|
||||||
|
* Текст факта.
|
||||||
|
* Может занимать несколько строк.
|
||||||
|
|
||||||
|
* § id=mem-yyy created=...
|
||||||
|
*
|
||||||
|
* Другой факт.
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Разделитель записей — строка, начинающаяся с `§ ` (section-symbol + пробел).
|
||||||
|
* Это позволяет использовать `§` внутри контента, если он не стоит в начале строки
|
||||||
|
* с пробелом после него. Парсер смотрит именно на `§<пробел>` в начале строки.
|
||||||
|
*
|
||||||
|
* Запись заканчивается за один пустой строкой перед следующим `§`-заголовком.
|
||||||
|
*/
|
||||||
|
object MdMemoryFormat {
|
||||||
|
|
||||||
|
private const val SECTION_PREFIX = "§ "
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Распарсить содержимое файла в список заметок. Неупорядоченно — порядок
|
||||||
|
* в файле не гарантирован, сортировка ложится на [MdMemoryStore].
|
||||||
|
*/
|
||||||
|
fun parse(category: MemoryCategory, body: String): List<MemoryNote> {
|
||||||
|
val lines = body.lines()
|
||||||
|
val out = mutableListOf<MemoryNote>()
|
||||||
|
var idx = 0
|
||||||
|
while (idx < lines.size) {
|
||||||
|
val line = lines[idx]
|
||||||
|
if (!line.startsWith(SECTION_PREFIX)) {
|
||||||
|
idx++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val headerLine = line.removePrefix(SECTION_PREFIX).trim()
|
||||||
|
idx++
|
||||||
|
// Следующая пустая строка после заголовка — пропускаем.
|
||||||
|
if (idx < lines.size && lines[idx].isBlank()) idx++
|
||||||
|
// Контент — до следующего `§`-заголовка или EOF.
|
||||||
|
val contentLines = mutableListOf<String>()
|
||||||
|
while (idx < lines.size && !lines[idx].startsWith(SECTION_PREFIX)) {
|
||||||
|
contentLines.add(lines[idx])
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
val note = parseNote(category, headerLine, contentLines.joinToString("\n").trim())
|
||||||
|
if (note != null) out.add(note)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сериализовать список заметок в содержимое файла. Записи идут в порядке
|
||||||
|
* передачи; между ними — пустая строка. В конце всегда перевод строки.
|
||||||
|
*/
|
||||||
|
fun serialize(notes: List<MemoryNote>): String = buildString {
|
||||||
|
for ((i, note) in notes.withIndex()) {
|
||||||
|
if (i > 0) append('\n')
|
||||||
|
append(SECTION_PREFIX)
|
||||||
|
append("id=").append(note.id)
|
||||||
|
append(" created=").append(note.createdAt.toString())
|
||||||
|
append(" last_used=").append(note.lastUsedAt.toString())
|
||||||
|
append(" uses=").append(note.useCount)
|
||||||
|
append(" source=").append(note.source.id)
|
||||||
|
if (note.conversationId != null) {
|
||||||
|
append(" conv=").append(note.conversationId)
|
||||||
|
}
|
||||||
|
append('\n').append('\n')
|
||||||
|
append(note.content)
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseNote(
|
||||||
|
category: MemoryCategory,
|
||||||
|
header: String,
|
||||||
|
content: String,
|
||||||
|
): MemoryNote? {
|
||||||
|
// Header: "id=<id> created=<iso> last_used=<iso> uses=<n> source=<id> [conv=<id>]"
|
||||||
|
var id: String? = null
|
||||||
|
var created: Instant? = null
|
||||||
|
var lastUsed: Instant? = null
|
||||||
|
var uses: Int? = null
|
||||||
|
var source: MemorySource? = null
|
||||||
|
var conv: String? = null
|
||||||
|
|
||||||
|
for (part in header.split(' ')) {
|
||||||
|
if (part.isEmpty()) continue
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq <= 0) continue
|
||||||
|
val key = part.substring(0, eq)
|
||||||
|
val value = part.substring(eq + 1)
|
||||||
|
try {
|
||||||
|
when (key) {
|
||||||
|
"id" -> id = value
|
||||||
|
"created" -> created = Instant.parse(value)
|
||||||
|
"last_used" -> lastUsed = Instant.parse(value)
|
||||||
|
"uses" -> uses = value.toInt()
|
||||||
|
"source" -> source = MemorySource.fromId(value)
|
||||||
|
"conv" -> conv = value
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id == null || created == null || lastUsed == null || uses == null || source == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return MemoryNote(
|
||||||
|
id = id,
|
||||||
|
category = category,
|
||||||
|
content = content,
|
||||||
|
createdAt = created,
|
||||||
|
lastUsedAt = lastUsed,
|
||||||
|
useCount = uses,
|
||||||
|
conversationId = conv,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Быстрый keyword-поиск по списку заметок. Используется внутри [MdMemoryStore]
|
||||||
|
* и [KeywordMdPrefetcher]. Возвращает результаты, отсортированные по score ↓.
|
||||||
|
*
|
||||||
|
* Алгоритм для v1: case-insensitive substring-match. Score = (число совпавших слов
|
||||||
|
* из запроса в заметке) / (общее число слов в запросе). Для пустого запроса
|
||||||
|
* отдаём все заметки, отсортированные по `lastUsedAt` ↓ (recency-фоллбэк).
|
||||||
|
*/
|
||||||
|
fun keywordScore(query: String, note: MemoryNote): Float {
|
||||||
|
val q = query.lowercase()
|
||||||
|
if (q.isBlank()) return 0f
|
||||||
|
val needle = q.splitToWords()
|
||||||
|
if (needle.isEmpty()) return 0f
|
||||||
|
val haystack = note.content.lowercase()
|
||||||
|
var hits = 0
|
||||||
|
for (w in needle) {
|
||||||
|
if (w.length >= 2 && w in haystack) hits++
|
||||||
|
}
|
||||||
|
return hits.toFloat() / needle.size.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.splitToWords(): List<String> =
|
||||||
|
split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() }
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.io.IOException
|
||||||
|
import kotlinx.io.buffered
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import kotlinx.io.files.SystemFileSystem
|
||||||
|
import kotlinx.io.readString
|
||||||
|
import kotlinx.io.writeString
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySearchResult
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hermes-style persistent memory store, backed by `kotlinx-io`.
|
||||||
|
*
|
||||||
|
* Каждая [MemoryCategory] живёт в отдельном файле под [root]:
|
||||||
|
* `USER.md`, `WORLD.md`, `PREFERENCES.md`. Записи разделены `§` и парсятся
|
||||||
|
* в [MemoryNote] при первом обращении к файлу. Все мутации идут под [mu],
|
||||||
|
* атомарно через `.tmp` + `atomicMove` ([SystemFileSystem.atomicMove]).
|
||||||
|
*
|
||||||
|
* Файлы инициализируются лениво — `~/.agentik/memory/{category}.md` создаётся
|
||||||
|
* при первом [upsert]/[get]/[list], не при [openMdMemory].
|
||||||
|
*/
|
||||||
|
class MdMemoryStore internal constructor(
|
||||||
|
private val root: Path,
|
||||||
|
) : MemoryStore {
|
||||||
|
|
||||||
|
private val mu = Mutex()
|
||||||
|
private val cache: MutableMap<MemoryCategory, MutableList<MemoryNote>> = HashMap()
|
||||||
|
private val dirty: MutableSet<MemoryCategory> = HashSet()
|
||||||
|
private val events = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 64)
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
SystemFileSystem.createDirectories(root, mustCreate = false)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw IllegalStateException("Cannot create memory root: $root", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observe(): SharedFlow<MemoryStoreEvent> = events.asSharedFlow()
|
||||||
|
|
||||||
|
private fun file(c: MemoryCategory): Path = Path(root, categoryFileName(c))
|
||||||
|
|
||||||
|
private fun ensureLoaded(c: MemoryCategory): MutableList<MemoryNote> {
|
||||||
|
cache[c]?.let { return it }
|
||||||
|
val path = file(c)
|
||||||
|
val notes: MutableList<MemoryNote> = if (SystemFileSystem.exists(path)) {
|
||||||
|
val text = SystemFileSystem.source(path).buffered().use { it.readString() }
|
||||||
|
MdMemoryFormat.parse(c, text).toMutableList()
|
||||||
|
} else {
|
||||||
|
mutableListOf()
|
||||||
|
}
|
||||||
|
cache[c] = notes
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persist(c: MemoryCategory) {
|
||||||
|
val notes = cache[c] ?: return
|
||||||
|
val path = file(c)
|
||||||
|
val tmp = Path(path.toString() + ".tmp")
|
||||||
|
SystemFileSystem.sink(tmp).buffered().use { it.writeString(MdMemoryFormat.serialize(notes)) }
|
||||||
|
try {
|
||||||
|
SystemFileSystem.atomicMove(tmp, path)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
runCatching { SystemFileSystem.delete(tmp, mustExist = false) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
dirty.remove(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsert(note: MemoryNote) {
|
||||||
|
val stored: MemoryNote
|
||||||
|
mu.withLock {
|
||||||
|
val list = ensureLoaded(note.category)
|
||||||
|
val idx = list.indexOfFirst { it.id == note.id }
|
||||||
|
stored = if (note.useCount == 0 && note.lastUsedAt == note.createdAt) {
|
||||||
|
note.copy(lastUsedAt = note.createdAt)
|
||||||
|
} else {
|
||||||
|
note
|
||||||
|
}
|
||||||
|
if (idx >= 0) list[idx] = stored else list.add(stored)
|
||||||
|
dirty.add(note.category)
|
||||||
|
persist(note.category)
|
||||||
|
}
|
||||||
|
events.tryEmit(MemoryStoreEvent.Upserted(stored))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(id: String): MemoryNote? = mu.withLock {
|
||||||
|
for (c in MemoryCategory.entries) {
|
||||||
|
val list = ensureLoaded(c)
|
||||||
|
val idx = list.indexOfFirst { it.id == id }
|
||||||
|
if (idx >= 0) return@withLock list[idx]
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
category: MemoryCategory?,
|
||||||
|
conversationId: String?,
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<MemoryNote> = mu.withLock {
|
||||||
|
val cats: List<MemoryCategory> =
|
||||||
|
category?.let { listOf(it) } ?: MemoryCategory.entries.toList()
|
||||||
|
val all = ArrayList<MemoryNote>(64)
|
||||||
|
for (c in cats) {
|
||||||
|
for (n in ensureLoaded(c)) {
|
||||||
|
if (conversationId == null) {
|
||||||
|
if (n.conversationId == null) all.add(n)
|
||||||
|
} else {
|
||||||
|
if (n.conversationId == conversationId) all.add(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.sortByDescending { it.createdAt }
|
||||||
|
val from = offset.coerceAtLeast(0)
|
||||||
|
if (from >= all.size) return@withLock emptyList()
|
||||||
|
val to = (from + limit).coerceAtMost(all.size)
|
||||||
|
all.subList(from, to).toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> = mu.withLock {
|
||||||
|
if (query.query.isBlank()) return@withLock emptyList()
|
||||||
|
val cats: List<MemoryCategory> =
|
||||||
|
query.category?.let { listOf(it) } ?: MemoryCategory.entries.toList()
|
||||||
|
val out = ArrayList<MemorySearchResult>()
|
||||||
|
for (c in cats) {
|
||||||
|
for (n in ensureLoaded(c)) {
|
||||||
|
if (query.conversationId != null &&
|
||||||
|
n.conversationId != null && n.conversationId != query.conversationId
|
||||||
|
) continue
|
||||||
|
val score = MdMemoryFormat.keywordScore(query.query, n)
|
||||||
|
if (score > 0f) out.add(MemorySearchResult(n, score))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sortByDescending { it.score }
|
||||||
|
if (query.topK > 0 && out.size > query.topK) {
|
||||||
|
out.subList(query.topK, out.size).clear()
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean = mu.withLock {
|
||||||
|
for (c in MemoryCategory.entries) {
|
||||||
|
val list = ensureLoaded(c)
|
||||||
|
val idx = list.indexOfFirst { it.id == id }
|
||||||
|
if (idx >= 0) {
|
||||||
|
list.removeAt(idx)
|
||||||
|
dirty.add(c)
|
||||||
|
persist(c)
|
||||||
|
events.tryEmit(MemoryStoreEvent.Deleted(id))
|
||||||
|
return@withLock true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markUsed(id: String, at: Instant) {
|
||||||
|
mu.withLock {
|
||||||
|
for (c in MemoryCategory.entries) {
|
||||||
|
val list = ensureLoaded(c)
|
||||||
|
val idx = list.indexOfFirst { it.id == id }
|
||||||
|
if (idx >= 0) {
|
||||||
|
val updated = list[idx].copy(lastUsedAt = at, useCount = list[idx].useCount + 1)
|
||||||
|
list[idx] = updated
|
||||||
|
dirty.add(c)
|
||||||
|
persist(c)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сбрасывает все буферизованные записи на диск. Идемпотентно. */
|
||||||
|
suspend fun flush() = mu.withLock {
|
||||||
|
for (c in dirty.toList()) persist(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching {
|
||||||
|
kotlinx.coroutines.runBlocking { flush() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Имя файла для категории: `user.md` / `world.md` / `preference.md`. */
|
||||||
|
internal fun categoryFileName(c: MemoryCategory): String = when (c) {
|
||||||
|
MemoryCategory.USER -> "user.md"
|
||||||
|
MemoryCategory.WORLD -> "world.md"
|
||||||
|
MemoryCategory.PREFERENCE -> "preference.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает [MdMemoryStore] в указанной корневой директории. Директория
|
||||||
|
* создаётся (рекурсивно), если её ещё нет.
|
||||||
|
*/
|
||||||
|
fun openMdMemory(root: Path): MdMemoryStore = MdMemoryStore(root)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemorySystem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Связка store + prefetcher + reviewer на одной физической базе.
|
||||||
|
* Сейчас всё держится на одном [MdMemoryStore] — keyword-префетчер и
|
||||||
|
* эвристический ревьюер смотрят в него же.
|
||||||
|
*/
|
||||||
|
class MdMemorySystem internal constructor(
|
||||||
|
override val store: MemoryStore,
|
||||||
|
override val prefetcher: MemoryPrefetcher,
|
||||||
|
override val reviewer: MemoryReviewer,
|
||||||
|
) : MemorySystem {
|
||||||
|
override fun close() = store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает [MdMemorySystem] для указанной корневой директории.
|
||||||
|
* Store и prefetcher смотрят в одну базу; reviewer — keyword-эвристика
|
||||||
|
* (LLM-импл добавится в `:standalone`).
|
||||||
|
*/
|
||||||
|
fun openMdMemorySystem(root: Path): MdMemorySystem {
|
||||||
|
val store = openMdMemory(root)
|
||||||
|
return MdMemorySystem(
|
||||||
|
store = store,
|
||||||
|
prefetcher = KeywordMdPrefetcher(store),
|
||||||
|
reviewer = KeywordMdReviewer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import kotlinx.io.files.SystemFileSystem
|
||||||
|
import kotlinx.io.files.SystemTemporaryDirectory
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
class KeywordMdPrefetcherTest {
|
||||||
|
|
||||||
|
private val idCounter = atomicCounter()
|
||||||
|
|
||||||
|
private fun newRoot(): Path {
|
||||||
|
val name = "agentik-mem-${uniqueId()}"
|
||||||
|
val root = Path(SystemTemporaryDirectory.toString(), name)
|
||||||
|
SystemFileSystem.createDirectories(root, mustCreate = true)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun note(id: String, category: MemoryCategory, content: String) = MemoryNote(
|
||||||
|
id = id, category = category, content = content,
|
||||||
|
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
source = MemorySource.AGENT_SAVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefetchReturnsRelevantAndBumpsUseCount() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemorySystem(root).use { sys ->
|
||||||
|
sys.store.upsert(note("u", MemoryCategory.USER, "User uses gradle 9.4.1"))
|
||||||
|
sys.store.upsert(note("w", MemoryCategory.WORLD, "Project runs on k3s"))
|
||||||
|
sys.store.upsert(note("p", MemoryCategory.PREFERENCE, "Prefers dark theme"))
|
||||||
|
val hits = sys.prefetcher.prefetch("gradle", topK = 5)
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
assertEquals("u", hits[0].id)
|
||||||
|
val after = sys.store.get("u")
|
||||||
|
assertTrue(after!!.useCount >= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyQueryReturnsNothing() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemorySystem(root).use { sys ->
|
||||||
|
sys.store.upsert(note("u", MemoryCategory.USER, "anything"))
|
||||||
|
assertTrue(sys.prefetcher.prefetch("").isEmpty())
|
||||||
|
assertTrue(sys.prefetcher.prefetch(" ").isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefetchRespectsCategory() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemorySystem(root).use { sys ->
|
||||||
|
sys.store.upsert(note("u", MemoryCategory.USER, "k8s tip"))
|
||||||
|
sys.store.upsert(note("w", MemoryCategory.WORLD, "k8s is great"))
|
||||||
|
val userOnly = sys.prefetcher.prefetch("k8s", topK = 5, category = MemoryCategory.USER)
|
||||||
|
assertEquals(listOf("u"), userOnly.map { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
class KeywordMdReviewerTest {
|
||||||
|
|
||||||
|
private val reviewer = KeywordMdReviewer()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun detectsUserPreference() = runBlocking {
|
||||||
|
val decision = reviewer.review(
|
||||||
|
ReviewedTurn(
|
||||||
|
userMessage = "Я обычно предпочитаю vim, а не emacs.",
|
||||||
|
assistantMessage = "Хорошо, запомнил.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(decision.toSave.isNotEmpty(), "should suggest at least one note")
|
||||||
|
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && (it.content.contains("vim") || it.content.contains("предпочитаю")) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ignoresNonPersonalStatements() = runBlocking {
|
||||||
|
val decision = reviewer.review(
|
||||||
|
ReviewedTurn(
|
||||||
|
userMessage = "Hello!",
|
||||||
|
assistantMessage = "Hi, I can help with anything.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handlesEnglishPreference() = runBlocking {
|
||||||
|
val decision = reviewer.review(
|
||||||
|
ReviewedTurn(
|
||||||
|
userMessage = "I usually prefer dark mode in my IDE.",
|
||||||
|
assistantMessage = "Got it.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicatesExactMatch() = runBlocking {
|
||||||
|
val decision = reviewer.review(
|
||||||
|
ReviewedTurn(
|
||||||
|
userMessage = "I usually prefer tab over spaces.\nI usually prefer tab over spaces.",
|
||||||
|
assistantMessage = "Ok.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val prefs = decision.toSave.filter { it.category == MemoryCategory.PREFERENCE }
|
||||||
|
assertEquals(1, prefs.size, "duplicate sentences should collapse")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preCompactionExtractsAcrossTurns() = runBlocking {
|
||||||
|
val decision = reviewer.reviewPreCompaction(
|
||||||
|
listOf(
|
||||||
|
ConversationTurn(
|
||||||
|
userMessage = "Я работаю на проекте agentik.",
|
||||||
|
assistantMessage = "Понял.",
|
||||||
|
),
|
||||||
|
ConversationTurn(
|
||||||
|
userMessage = "Я обычно использую kotlin для бэкенда.",
|
||||||
|
assistantMessage = "Хорошо.",
|
||||||
|
),
|
||||||
|
ConversationTurn(
|
||||||
|
userMessage = "Мне нравится архитектура memory-first.",
|
||||||
|
assistantMessage = "Согласен.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// 3 user-фразы с маркерами — должно дать 3 факта.
|
||||||
|
assertEquals(3, decision.toSave.size, "should extract one fact per user phrase")
|
||||||
|
assertTrue(decision.toSave.any { it.category == MemoryCategory.USER && it.content.contains("agentik") })
|
||||||
|
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") })
|
||||||
|
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("memory-first") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preCompactionDedupesAcrossTurns() = runBlocking {
|
||||||
|
val decision = reviewer.reviewPreCompaction(
|
||||||
|
listOf(
|
||||||
|
ConversationTurn(
|
||||||
|
userMessage = "Я обычно предпочитаю vim.",
|
||||||
|
assistantMessage = "A.",
|
||||||
|
),
|
||||||
|
ConversationTurn(
|
||||||
|
userMessage = "Я обычно предпочитаю vim.",
|
||||||
|
assistantMessage = "B.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val prefs = decision.toSave.filter { it.category == MemoryCategory.PREFERENCE }
|
||||||
|
assertEquals(1, prefs.size, "duplicate facts across turns should collapse")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preCompactionRespectsTotalLimit() = runBlocking {
|
||||||
|
val reviewer = KeywordMdReviewer(maxFactsTotal = 2)
|
||||||
|
val decision = reviewer.reviewPreCompaction(
|
||||||
|
(1..5).map {
|
||||||
|
ConversationTurn(
|
||||||
|
userMessage = "Я работаю над задачей #$it.",
|
||||||
|
assistantMessage = "ok",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertEquals(2, decision.toSave.size, "should respect maxFactsTotal across turns")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preCompactionHandlesEmptyList() = runBlocking {
|
||||||
|
val decision = reviewer.reviewPreCompaction(emptyList())
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class MdMemoryFormatTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesAndSerializes() {
|
||||||
|
val n = MemoryNote(
|
||||||
|
id = "mem-1",
|
||||||
|
category = MemoryCategory.USER,
|
||||||
|
content = "Hello, world!",
|
||||||
|
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
source = MemorySource.AGENT_SAVE,
|
||||||
|
)
|
||||||
|
val body = MdMemoryFormat.serialize(listOf(n))
|
||||||
|
val parsed = MdMemoryFormat.parse(MemoryCategory.USER, body)
|
||||||
|
assertEquals(1, parsed.size)
|
||||||
|
assertEquals(n, parsed[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preservesMultiLineContent() {
|
||||||
|
val n = MemoryNote(
|
||||||
|
id = "mem-multi",
|
||||||
|
category = MemoryCategory.WORLD,
|
||||||
|
content = "Line1\nLine2\nLine3",
|
||||||
|
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
source = MemorySource.AGENT_SAVE,
|
||||||
|
)
|
||||||
|
val body = MdMemoryFormat.serialize(listOf(n))
|
||||||
|
val parsed = MdMemoryFormat.parse(MemoryCategory.WORLD, body)
|
||||||
|
assertEquals(n.content, parsed[0].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keywordScore() {
|
||||||
|
val n = MemoryNote(
|
||||||
|
id = "k", category = MemoryCategory.WORLD,
|
||||||
|
content = "k8s kubectl kustomize",
|
||||||
|
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
source = MemorySource.AGENT_SAVE,
|
||||||
|
)
|
||||||
|
assertTrue(MdMemoryFormat.keywordScore("k8s", n) > 0f)
|
||||||
|
assertEquals(0f, MdMemoryFormat.keywordScore("python", n))
|
||||||
|
assertEquals(0f, MdMemoryFormat.keywordScore("", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import kotlinx.io.files.SystemFileSystem
|
||||||
|
import kotlinx.io.files.SystemTemporaryDirectory
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
class MdMemoryStoreTest {
|
||||||
|
|
||||||
|
private val idCounter = atomicCounter()
|
||||||
|
|
||||||
|
private fun newRoot(): Path {
|
||||||
|
val name = "agentik-mem-${uniqueId()}"
|
||||||
|
val root = Path(SystemTemporaryDirectory.toString(), name)
|
||||||
|
SystemFileSystem.createDirectories(root, mustCreate = true)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun note(
|
||||||
|
id: String = "mem-${idCounter.next()}",
|
||||||
|
category: MemoryCategory = MemoryCategory.USER,
|
||||||
|
content: String,
|
||||||
|
createdAt: Instant = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
lastUsedAt: Instant = createdAt,
|
||||||
|
useCount: Int = 0,
|
||||||
|
conversationId: String? = null,
|
||||||
|
source: MemorySource = MemorySource.AGENT_SAVE,
|
||||||
|
) = MemoryNote(
|
||||||
|
id = id, category = category, content = content,
|
||||||
|
createdAt = createdAt, lastUsedAt = lastUsedAt, useCount = useCount,
|
||||||
|
conversationId = conversationId, source = source,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripSingleEntry() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
val n = note(
|
||||||
|
id = "mem-test-1",
|
||||||
|
category = MemoryCategory.USER,
|
||||||
|
content = "User prefers dark mode.",
|
||||||
|
conversationId = null,
|
||||||
|
)
|
||||||
|
store.upsert(n)
|
||||||
|
assertEquals(n, store.get("mem-test-1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun persistsAcrossReopen() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
val n1 = note(id = "mem-a", category = MemoryCategory.USER, content = "alpha")
|
||||||
|
val n2 = note(id = "mem-b", category = MemoryCategory.WORLD, content = "beta")
|
||||||
|
val n3 = note(id = "mem-c", category = MemoryCategory.PREFERENCE, content = "gamma")
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
store.upsert(n1)
|
||||||
|
store.upsert(n2)
|
||||||
|
store.upsert(n3)
|
||||||
|
}
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
assertEquals(n1, store.get("mem-a"))
|
||||||
|
assertEquals(n2, store.get("mem-b"))
|
||||||
|
assertEquals(n3, store.get("mem-c"))
|
||||||
|
val all = store.list()
|
||||||
|
assertEquals(3, all.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upsertReplacesById() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
store.upsert(note(id = "mem-1", content = "first"))
|
||||||
|
store.upsert(note(id = "mem-1", content = "second"))
|
||||||
|
assertEquals("second", store.get("mem-1")?.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesById() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
store.upsert(note(id = "mem-x", category = MemoryCategory.WORLD, content = "go"))
|
||||||
|
assertEquals(true, store.delete("mem-x"))
|
||||||
|
assertNull(store.get("mem-x"))
|
||||||
|
assertEquals(false, store.delete("mem-x"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchFiltersByCategoryAndScoresSubstring() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
store.upsert(note(id = "u1", category = MemoryCategory.USER, content = "k8s cluster is using kubeadm"))
|
||||||
|
store.upsert(note(id = "u2", category = MemoryCategory.USER, content = "loves cats and code"))
|
||||||
|
store.upsert(note(id = "w1", category = MemoryCategory.WORLD, content = "project runs on k8s"))
|
||||||
|
store.upsert(note(id = "p1", category = MemoryCategory.PREFERENCE, content = "prefers dark theme"))
|
||||||
|
val userOnly = store.search(MemorySearchQuery("k8s", topK = 10, category = MemoryCategory.USER))
|
||||||
|
assertEquals(1, userOnly.size)
|
||||||
|
assertEquals("u1", userOnly[0].note.id)
|
||||||
|
val all = store.search(MemorySearchQuery("k8s", topK = 10))
|
||||||
|
assertEquals(2, all.size)
|
||||||
|
val ordered = all.map { it.note.id }.toSet()
|
||||||
|
assertTrue(ordered.containsAll(listOf("u1", "w1")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listFilterByConversationId() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
store.upsert(note(id = "g1", content = "global fact", conversationId = null))
|
||||||
|
store.upsert(note(id = "c1", content = "per-conv", conversationId = "conv-x"))
|
||||||
|
val global = store.list(conversationId = null)
|
||||||
|
assertEquals(1, global.size)
|
||||||
|
assertEquals("g1", global[0].id)
|
||||||
|
val perConv = store.list(conversationId = "conv-x")
|
||||||
|
assertEquals(1, perConv.size)
|
||||||
|
assertEquals("c1", perConv[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markUsedBumpsCountAndLastUsed() = runBlocking {
|
||||||
|
val root = newRoot()
|
||||||
|
openMdMemory(root).use { store ->
|
||||||
|
store.upsert(note(id = "m", content = "x", lastUsedAt = Instant.parse("2026-09-01T00:00:00Z"), useCount = 0))
|
||||||
|
store.markUsed("m", Instant.parse("2026-09-14T10:00:00Z"))
|
||||||
|
val after = store.get("m")
|
||||||
|
assertNotNull(after)
|
||||||
|
assertEquals(1, after.useCount)
|
||||||
|
assertEquals(Instant.parse("2026-09-14T10:00:00Z"), after.lastUsedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package pw.binom.agentik.memory.md
|
||||||
|
|
||||||
|
import kotlin.concurrent.atomics.AtomicInt
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.concurrent.atomics.incrementAndFetch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простой потокобезопасный счётчик для генерации уникальных id в тестах.
|
||||||
|
* Работает на всех KMP-таргетах (JVM + native), в отличие от `java.util.UUID`.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
internal class AtomicCounter {
|
||||||
|
private val v = AtomicInt(0)
|
||||||
|
fun next(): Int = v.incrementAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun atomicCounter(): AtomicCounter = AtomicCounter()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-wide уникальный id — комбинация nanos + счётчика.
|
||||||
|
* Гарантирует уникальность имени временной директории при параллельных тестах.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private val processCounter = AtomicInt(0)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
internal fun uniqueId(): String {
|
||||||
|
val n = processCounter.incrementAndFetch()
|
||||||
|
val ts = kotlin.time.Clock.System.now().toEpochMilliseconds()
|
||||||
|
return "${ts}-${n}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# `:memory-vector` — ANN/JVector/SQLite память с эмбеддингами (JVM-only)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Реализация `MemoryStore` поверх SQLite + [JVector](https://github.com/jbellis/jvector)
|
||||||
|
+ LLM-эмбеддинги:
|
||||||
|
|
||||||
|
- **Хранение метаданных** — SQLite (notes, timestamps, источник).
|
||||||
|
- **ANN-индекс** — JVector (тот же класс HNSW, что используется в
|
||||||
|
Cassandra DataStax).
|
||||||
|
- **Эмбеддинги** — два backendа:
|
||||||
|
- **HTTP** — POST на любой OpenAI-совместимый `/v1/embeddings`
|
||||||
|
(vLLM, LiteLLM, text-embedding-ada-002, и т.д.).
|
||||||
|
- **SigLIP2** — локальная модель через [text-embedding-kmp](https://git.binom.pw/subochev/text-embedding-kmp)
|
||||||
|
(ONNX Runtime, без сети).
|
||||||
|
|
||||||
|
Решает: семантический поиск по памяти. "Где я рассказывал про
|
||||||
|
CI/CD" находит нужный эпизод, даже если формулировка другая. При
|
||||||
|
этом offline-capable через SigLIP.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` подключает как `AGENTIK_MEMORY_BACKEND=vector`
|
||||||
|
(с `AGENTIK_EMBEDDING_BACKEND=http|siglip`).
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("pw.binom.agentik:memory-vector:0.1.0")
|
||||||
|
implementation("pw.binom.agentik:memory-api:0.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
val memory = VectorMemorySystem.open(
|
||||||
|
dbPath = Path("~/.agentik/mem.db"),
|
||||||
|
embedding = HttpEmbeddingClient(
|
||||||
|
apiUrl = "http://192.168.88.135:8001/v1",
|
||||||
|
apiKey = "no-key-needed",
|
||||||
|
model = "text-embedding-3-small",
|
||||||
|
dimension = 1536,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-memory-vector`.
|
||||||
|
|
||||||
|
**Зависит от** `pw.binom.ai.embeddingtext:api-jvm:3.0.0-SNAPSHOT`
|
||||||
|
и `pw.binom.ai.embeddingtext:siglip-jvm:3.0.0-SNAPSHOT` из репо
|
||||||
|
`caffeine` (см. `../gradle/libs.versions.toml`). Оба опубликованы
|
||||||
|
вручную (`Binom-PIN-Caffeine`).
|
||||||
|
|
||||||
|
## Как работает embedding-флоу
|
||||||
|
|
||||||
|
1. `memory.save(cat, "text")` — text → embedding (HTTP или SigLIP)
|
||||||
|
→ row в SQLite + вектор в JVector-индекс.
|
||||||
|
2. `memory.query(cat, "q")` — q → embedding → ANN top-K (default K=10)
|
||||||
|
→ скоры, deduplication, реплес с timestamp.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :memory-vector:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: round-trip, ANN top-K, SigLIP (если модель скачана),
|
||||||
|
SQLite-migration. SigLIP-тест skipped без модели на диске.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого HTTP-клиента к LLM для генерации ответов. Это только
|
||||||
|
embedding-клиент. Сам LLM-вызов — в `:standalone`.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Подходит для крупных памятей (10000+
|
||||||
|
заметок) и семантических запросов.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
|
||||||
|
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не
|
||||||
|
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
|
||||||
|
// Использование:
|
||||||
|
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
||||||
|
// Локальная разработка без флага — зависимости подключаются как обычно.
|
||||||
|
val skipVectorMemory: Boolean =
|
||||||
|
(project.findProperty("skipVectorMemory") == "true") ||
|
||||||
|
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// Vector-бэкенд JVM-only: JVector не публикует KMP-таргеты, но его Java 11
|
||||||
|
// base jar работает на Android ART через scalar fallback. Для desktop JVM
|
||||||
|
// HotSpot 21+ автоматически подхватывается Panama Vector API (multirelease).
|
||||||
|
jvm()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":memory-api"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(libs.jvector)
|
||||||
|
implementation(libs.sqldelight.sqlite.driver)
|
||||||
|
}
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("jvmMainApi", libs.text.embedding.api)
|
||||||
|
add("jvmMainImplementation", libs.text.embedding.siglip)
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдер эмбеддингов: превращает текст в FloatArray фиксированной размерности.
|
||||||
|
*
|
||||||
|
* Реализация по умолчанию — HTTP-вызов `POST /v1/embeddings` к OpenAI-совместимому
|
||||||
|
* API (OpenAI / litellm-proxy / vllm). С LRU-кэшом, чтобы не ходить в сеть
|
||||||
|
* на каждый search/upsert.
|
||||||
|
*/
|
||||||
|
interface EmbeddingProvider {
|
||||||
|
val dimension: Int
|
||||||
|
suspend fun embed(text: String): FloatArray
|
||||||
|
|
||||||
|
/** Batch-вариант. По умолчанию — последовательный вызов [embed]. */
|
||||||
|
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
||||||
|
texts.map { embed(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детерминированный провайдер для тестов: хеширует текст в псевдо-вектор.
|
||||||
|
* Используется только в commonTest; в продакшн заменяется на HttpEmbeddingProvider.
|
||||||
|
*/
|
||||||
|
class FakeEmbeddingProvider(override val dimension: Int = 32) : EmbeddingProvider {
|
||||||
|
override suspend fun embed(text: String): FloatArray {
|
||||||
|
val v = FloatArray(dimension)
|
||||||
|
// Простейший детерминированный seed — сумма char'ов по модулю.
|
||||||
|
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
||||||
|
for (i in 0 until dimension) {
|
||||||
|
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||||
|
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||||
|
}
|
||||||
|
// L2-normalize чтобы cosine работал осмысленно.
|
||||||
|
var norm = 0f
|
||||||
|
for (x in v) norm += x * x
|
||||||
|
norm = kotlin.math.sqrt(norm)
|
||||||
|
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хранилище метаданных и embeddings заметок. Реализация по умолчанию —
|
||||||
|
* SQLite (`SqliteMemoryMetaStore`).
|
||||||
|
*
|
||||||
|
* Это источник правды: [VectorMemoryIndex] (JVector) держит in-RAM ANN-индекс,
|
||||||
|
* который пересобирается из [allEntries] при старте. Вектор хранится рядом с
|
||||||
|
* метаданными — как packed little-endian Float32Array (`dim` * 4 байт).
|
||||||
|
*
|
||||||
|
* Скрывает детали backend'а от [VectorMemoryStore], который живёт в commonMain
|
||||||
|
* и не знает про SQLite.
|
||||||
|
*/
|
||||||
|
interface MemoryMetaStore : AutoCloseable {
|
||||||
|
/** Записать заметку и её embedding. Идемпотентно по [note]`.id`. */
|
||||||
|
fun put(note: MemoryNote, embedding: FloatArray)
|
||||||
|
|
||||||
|
/** Заметка по id, без вектора. */
|
||||||
|
fun get(id: String): MemoryNote?
|
||||||
|
|
||||||
|
/** Все (id, embedding) — для пересборки vector-индекса при старте. */
|
||||||
|
fun allEntries(): List<Pair<String, FloatArray>>
|
||||||
|
|
||||||
|
/** Пагинированный листинг заметок с опциональными фильтрами. */
|
||||||
|
fun list(category: MemoryCategory?, conversationId: String?, limit: Int, offset: Int): List<MemoryNote>
|
||||||
|
|
||||||
|
/** Удалить заметку и её embedding. Возвращает true если запись была. */
|
||||||
|
fun delete(id: String): Boolean
|
||||||
|
|
||||||
|
/** Обновить `last_used_at` (и увеличить `use_count`) для [id]. */
|
||||||
|
fun markUsed(id: String, at: Instant)
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1].
|
||||||
|
* Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||||
|
*/
|
||||||
|
data class ScoredVector(
|
||||||
|
val id: String,
|
||||||
|
val score: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||||
|
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
|
||||||
|
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
|
||||||
|
*
|
||||||
|
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||||
|
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это
|
||||||
|
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||||
|
*/
|
||||||
|
interface MemoryVectorIndex : AutoCloseable {
|
||||||
|
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||||
|
val dimension: Int
|
||||||
|
|
||||||
|
/** Количество записей в индексе. */
|
||||||
|
suspend fun size(): Long
|
||||||
|
|
||||||
|
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
|
||||||
|
suspend fun add(id: String, embedding: FloatArray)
|
||||||
|
|
||||||
|
/** Удалить запись по [id]. Возвращает true если запись была. */
|
||||||
|
suspend fun remove(id: String): Boolean
|
||||||
|
|
||||||
|
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id (например, по категории). */
|
||||||
|
suspend fun search(
|
||||||
|
query: FloatArray,
|
||||||
|
k: Int,
|
||||||
|
filter: (MemoryNote) -> Boolean = { true },
|
||||||
|
): List<ScoredVector>
|
||||||
|
|
||||||
|
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
|
||||||
|
suspend fun flush()
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доп. контекст для vector-индекса: фильтр по категории и conversationId
|
||||||
|
* передаётся через замыкание, которое получает [MemoryNote]. Так [MemoryStore]
|
||||||
|
* остаётся единственным источником правды по метаданным.
|
||||||
|
*/
|
||||||
|
fun noteMatches(
|
||||||
|
note: MemoryNote,
|
||||||
|
category: MemoryCategory? = null,
|
||||||
|
conversationId: String? = null,
|
||||||
|
): Boolean {
|
||||||
|
if (category != null && note.category != category) return false
|
||||||
|
if (conversationId != null && note.conversationId != conversationId) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySearchResult
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MemoryStore поверх (index + metadata). Метаданные заметок хранятся
|
||||||
|
* в [metaStore] (SQLite-таблица), эмбеддинги — в [index] (JVector on-disk graph).
|
||||||
|
*
|
||||||
|
* Контракт MemoryStore требует, чтобы [upsert] атомарно обновлял и метаданные,
|
||||||
|
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
|
||||||
|
* потом re-rank по recency.
|
||||||
|
*
|
||||||
|
* [embeddingProvider] обязателен — используется для эмбеддинга контента при
|
||||||
|
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
|
||||||
|
*/
|
||||||
|
class VectorMemoryStore(
|
||||||
|
private val index: MemoryVectorIndex,
|
||||||
|
private val metaStore: MemoryMetaStore,
|
||||||
|
private val embeddingProvider: EmbeddingProvider,
|
||||||
|
) : MemoryStore {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val _events = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 64)
|
||||||
|
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
|
||||||
|
val embedding = embeddingProvider.embed(note.content)
|
||||||
|
metaStore.put(note, embedding)
|
||||||
|
index.add(note.id, embedding)
|
||||||
|
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(id: String): MemoryNote? = metaStore.get(id)
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
category: MemoryCategory?,
|
||||||
|
conversationId: String?,
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
|
||||||
|
|
||||||
|
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
|
||||||
|
val queryEmbedding = embeddingProvider.embed(query.query)
|
||||||
|
val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
|
||||||
|
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
|
||||||
|
// через [metaStore.get] + [noteMatches] отрежет лишних.
|
||||||
|
val candidates = index.search(
|
||||||
|
query = queryEmbedding,
|
||||||
|
k = overFetch,
|
||||||
|
filter = { true },
|
||||||
|
)
|
||||||
|
// Re-rank: 0.7 * cosine + 0.3 * recency_weight
|
||||||
|
// recency_weight = exp(-age_days / 30) — half-life месяц.
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val scored = candidates.mapNotNull { sv ->
|
||||||
|
val note = metaStore.get(sv.id) ?: return@mapNotNull null
|
||||||
|
if (!noteMatches(note, query.category, query.conversationId)) return@mapNotNull null
|
||||||
|
val ageDays = (now - note.lastUsedAt).inWholeDays.toDouble()
|
||||||
|
val recency = exp(-ageDays / 30.0).toFloat()
|
||||||
|
val finalScore = 0.7f * sv.score + 0.3f * recency
|
||||||
|
MemorySearchResult(note = note, score = finalScore)
|
||||||
|
}
|
||||||
|
return scored.sortedByDescending { it.score }.take(query.topK)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean = mutex.withLock {
|
||||||
|
val existed = metaStore.delete(id)
|
||||||
|
if (existed) {
|
||||||
|
index.remove(id)
|
||||||
|
_events.emit(MemoryStoreEvent.Deleted(id))
|
||||||
|
}
|
||||||
|
existed
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markUsed(id: String, at: Instant) {
|
||||||
|
metaStore.markUsed(id, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
index.close()
|
||||||
|
metaStore.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import io.github.jbellis.jvector.graph.GraphIndexBuilder
|
||||||
|
import io.github.jbellis.jvector.graph.GraphSearcher
|
||||||
|
import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues
|
||||||
|
import io.github.jbellis.jvector.graph.OnHeapGraphIndex
|
||||||
|
import io.github.jbellis.jvector.graph.SearchResult
|
||||||
|
import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider
|
||||||
|
import io.github.jbellis.jvector.util.Bits
|
||||||
|
import io.github.jbellis.jvector.vector.VectorizationProvider
|
||||||
|
import io.github.jbellis.jvector.vector.VectorSimilarityFunction
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||||
|
import kotlin.concurrent.read
|
||||||
|
import kotlin.concurrent.write
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-RAM ANN-индекс поверх JVector.
|
||||||
|
*
|
||||||
|
* Семантика хранения: **источник правды — SQLite (см. MemoryMetaStore)**.
|
||||||
|
* Этот класс держит в heap'е [OnHeapGraphIndex] + mapping id ↔ ordinal и
|
||||||
|
* пересобирается из [seedEntries] при конструировании. На каждом [add]/[remove]
|
||||||
|
* граф перестраивается полностью (для 10K vectors это <100ms).
|
||||||
|
*
|
||||||
|
* **Что НЕ делается**: persist через OnDiskGraphIndex. JVector'у для записи
|
||||||
|
* на диск нужна Feature с INLINE_VECTORS, которая (в текущей версии 4.0.0)
|
||||||
|
* конфигурируется отдельно и сложно. SQLite BLOB дешевле и проще — она и
|
||||||
|
* хранит embedding'и. Граф реконструируется из SQLite при старте.
|
||||||
|
*
|
||||||
|
* Потокобезопасность: [ReentrantReadWriteLock] — параллельные [search] ок,
|
||||||
|
* [add]/[remove] — эксклюзивно.
|
||||||
|
*/
|
||||||
|
class JVectorMemoryIndex(
|
||||||
|
override val dimension: Int,
|
||||||
|
seedEntries: List<Pair<String, FloatArray>> = emptyList(),
|
||||||
|
) : MemoryVectorIndex {
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(seedEntries.all { it.second.size == dimension }) {
|
||||||
|
"all seed embeddings must have dimension=$dimension"
|
||||||
|
}
|
||||||
|
require(seedEntries.map { it.first }.toSet().size == seedEntries.size) {
|
||||||
|
"duplicate ids in seedEntries"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val rwLock = ReentrantReadWriteLock()
|
||||||
|
|
||||||
|
private val vts = VectorizationProvider.getInstance().getVectorTypeSupport()
|
||||||
|
private val similarity = VectorSimilarityFunction.COSINE
|
||||||
|
|
||||||
|
// In-RAM state. Защищён rwLock.
|
||||||
|
private val idToOrdinal = LinkedHashMap<String, Int>()
|
||||||
|
private val ordinalToId = ArrayList<String>(seedEntries.size + 16)
|
||||||
|
private val ordinalToVector = ArrayList<FloatArray>(seedEntries.size + 16)
|
||||||
|
private val deleted = java.util.BitSet()
|
||||||
|
private var graph: OnHeapGraphIndex? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
seedEntries.forEach { (id, vec) ->
|
||||||
|
val ord = ordinalToId.size
|
||||||
|
idToOrdinal[id] = ord
|
||||||
|
ordinalToId.add(id)
|
||||||
|
ordinalToVector.add(vec)
|
||||||
|
}
|
||||||
|
if (ordinalToId.isNotEmpty()) {
|
||||||
|
graph = rebuildFromScratch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun size(): Long = rwLock.read {
|
||||||
|
(ordinalToId.size - deleted.cardinality()).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun add(id: String, embedding: FloatArray) = rwLock.write {
|
||||||
|
require(embedding.size == dimension) {
|
||||||
|
"embedding size ${embedding.size} != dimension $dimension"
|
||||||
|
}
|
||||||
|
val existing = idToOrdinal[id]
|
||||||
|
if (existing != null) {
|
||||||
|
ordinalToVector[existing] = embedding
|
||||||
|
deleted.clear(existing)
|
||||||
|
} else {
|
||||||
|
val ord = ordinalToId.size
|
||||||
|
idToOrdinal[id] = ord
|
||||||
|
ordinalToId.add(id)
|
||||||
|
ordinalToVector.add(embedding)
|
||||||
|
}
|
||||||
|
rebuildAndSwapGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(id: String): Boolean = rwLock.write {
|
||||||
|
val ord = idToOrdinal[id] ?: return false
|
||||||
|
deleted.set(ord)
|
||||||
|
rebuildAndSwapGraph()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(
|
||||||
|
query: FloatArray,
|
||||||
|
k: Int,
|
||||||
|
filter: (MemoryNote) -> Boolean,
|
||||||
|
): List<ScoredVector> = rwLock.read {
|
||||||
|
require(query.size == dimension) {
|
||||||
|
"query size ${query.size} != dimension $dimension"
|
||||||
|
}
|
||||||
|
if (k <= 0 || graph == null) return emptyList()
|
||||||
|
val activeOrdinals = (0 until ordinalToId.size).filter { !deleted.get(it) }
|
||||||
|
if (activeOrdinals.isEmpty()) return emptyList()
|
||||||
|
val vectors = activeOrdinals.map { vts.createFloatVector(ordinalToVector[it]) }
|
||||||
|
val ravv = ListRandomAccessVectorValues(vectors, dimension)
|
||||||
|
val queryVec = vts.createFloatVector(query)
|
||||||
|
val result: SearchResult = GraphSearcher.search(
|
||||||
|
queryVec,
|
||||||
|
k.coerceAtMost(activeOrdinals.size),
|
||||||
|
ravv,
|
||||||
|
similarity,
|
||||||
|
graph!!,
|
||||||
|
Bits.ALL,
|
||||||
|
)
|
||||||
|
val nodes: Array<SearchResult.NodeScore> = result.getNodes()
|
||||||
|
val out = ArrayList<ScoredVector>(nodes.size)
|
||||||
|
for (ns in nodes) {
|
||||||
|
val realOrd = activeOrdinals[ns.node]
|
||||||
|
out.add(ScoredVector(id = ordinalToId[realOrd], score = ns.score))
|
||||||
|
}
|
||||||
|
// [filter] применяется в [VectorMemoryStore] по MemoryNote (там есть category/convId).
|
||||||
|
// Контракт JVector — фильтрация через Bits, что здесь неудобно, поэтому
|
||||||
|
// делегируем фильтр наверх.
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
// No-op: граф в RAM, источник правды — SQLite. flush не требуется.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
rwLock.write {
|
||||||
|
graph?.close()
|
||||||
|
graph = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildAndSwapGraph() {
|
||||||
|
val newGraph = rebuildFromScratch()
|
||||||
|
val old = graph
|
||||||
|
graph = newGraph
|
||||||
|
old?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildFromScratch(): OnHeapGraphIndex {
|
||||||
|
val activeOrdinals = (0 until ordinalToId.size).filter { !deleted.get(it) }
|
||||||
|
val vectors = activeOrdinals.map { vts.createFloatVector(ordinalToVector[it]) }
|
||||||
|
val ravv = ListRandomAccessVectorValues(vectors, dimension)
|
||||||
|
val bsp = BuildScoreProvider.randomAccessScoreProvider(ravv, similarity)
|
||||||
|
// Параметры графа по умолчанию (как в JVector README):
|
||||||
|
// - M (max degree) = 16..32 — больше = точнее, медленнее
|
||||||
|
// - efConstruction = 100..200 — больше = точнее, дольше строить
|
||||||
|
// Для нашего масштаба (10K) берём средние значения.
|
||||||
|
val M = 16
|
||||||
|
val efConstruction = 100
|
||||||
|
val neighborOverflow = 1.2f
|
||||||
|
val alpha = 1.2f
|
||||||
|
return GraphIndexBuilder(bsp, dimension, M, efConstruction, neighborOverflow, alpha).use { builder ->
|
||||||
|
builder.build(ravv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.sql.Connection
|
||||||
|
import java.sql.DriverManager
|
||||||
|
import java.sql.PreparedStatement
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хранилище метаданных заметок + их эмбеддингов в SQLite.
|
||||||
|
*
|
||||||
|
* Схема (`memory_note_meta`):
|
||||||
|
* - `id` — TEXT PRIMARY KEY
|
||||||
|
* - `category`, `source` — TEXT (id enum'ов)
|
||||||
|
* - `content` — TEXT
|
||||||
|
* - `created_at`, `last_used_at` — INTEGER (epoch ms)
|
||||||
|
* - `use_count` — INTEGER
|
||||||
|
* - `conversation_id` — TEXT NULL
|
||||||
|
* - `embedding` — BLOB (packed Float32Array, dim * 4 bytes, little-endian)
|
||||||
|
*
|
||||||
|
* Это **источник правды** для vector-бэкенда. JVector-индекс — in-RAM,
|
||||||
|
* пересобирается из [allEntries] при старте. См. [JVectorMemoryIndex].
|
||||||
|
*
|
||||||
|
* Можно шарить один `agentik.db` с conversation DB — таблицы не пересекаются.
|
||||||
|
*
|
||||||
|
* Потокобезопасность: рассчитывает на single-connection-per-instance,
|
||||||
|
* синхронизация на уровне [VectorMemoryStore] (mutex на upsert/delete).
|
||||||
|
*/
|
||||||
|
class SqliteMemoryMetaStore(
|
||||||
|
private val conn: Connection,
|
||||||
|
private val dimension: Int,
|
||||||
|
) : MemoryMetaStore {
|
||||||
|
|
||||||
|
/** Открыть отдельный файл (например, `~/.agentik/agentik.db` для шаринга). */
|
||||||
|
constructor(jdbcUrl: String, dimension: Int) : this(
|
||||||
|
DriverManager.getConnection(jdbcUrl).apply {
|
||||||
|
createStatement().use { st ->
|
||||||
|
st.execute("PRAGMA foreign_keys = ON")
|
||||||
|
st.execute("PRAGMA journal_mode = WAL")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dimension,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val initialized = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
|
||||||
|
private fun ensureSchema() {
|
||||||
|
if (initialized.get()) return
|
||||||
|
conn.createStatement().use { st ->
|
||||||
|
st.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_note_meta (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER NOT NULL,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
conversation_id TEXT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
embedding BLOB NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
st.execute("CREATE INDEX IF NOT EXISTS memory_note_meta_cat ON memory_note_meta(category)")
|
||||||
|
st.execute("CREATE INDEX IF NOT EXISTS memory_note_meta_lu ON memory_note_meta(last_used_at DESC)")
|
||||||
|
}
|
||||||
|
initialized.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun put(note: MemoryNote, embedding: FloatArray) {
|
||||||
|
ensureSchema()
|
||||||
|
require(embedding.size == dimension) {
|
||||||
|
"embedding size ${embedding.size} != dimension $dimension"
|
||||||
|
}
|
||||||
|
val blob = embedding.toLittleEndianBytes()
|
||||||
|
conn.prepareStatement(
|
||||||
|
"""
|
||||||
|
INSERT INTO memory_note_meta(id, category, content, created_at, last_used_at,
|
||||||
|
use_count, conversation_id, source, embedding)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
category = excluded.category,
|
||||||
|
content = excluded.content,
|
||||||
|
created_at = excluded.created_at,
|
||||||
|
last_used_at = excluded.last_used_at,
|
||||||
|
use_count = excluded.use_count,
|
||||||
|
conversation_id = excluded.conversation_id,
|
||||||
|
source = excluded.source,
|
||||||
|
embedding = excluded.embedding
|
||||||
|
""".trimIndent()
|
||||||
|
).use { ps ->
|
||||||
|
ps.setString(1, note.id)
|
||||||
|
ps.setString(2, note.category.id)
|
||||||
|
ps.setString(3, note.content)
|
||||||
|
ps.setLong(4, note.createdAt.toEpochMilliseconds())
|
||||||
|
ps.setLong(5, note.lastUsedAt.toEpochMilliseconds())
|
||||||
|
ps.setInt(6, note.useCount)
|
||||||
|
ps.setString(7, note.conversationId)
|
||||||
|
ps.setString(8, note.source.id)
|
||||||
|
ps.setBytes(9, blob)
|
||||||
|
ps.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(id: String): MemoryNote? {
|
||||||
|
ensureSchema()
|
||||||
|
conn.prepareStatement(
|
||||||
|
"SELECT category, content, created_at, last_used_at, use_count, conversation_id, source FROM memory_note_meta WHERE id = ?"
|
||||||
|
).use { ps ->
|
||||||
|
ps.setString(1, id)
|
||||||
|
ps.executeQuery().use { rs ->
|
||||||
|
return if (rs.next()) rs.toNote(id) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun allEntries(): List<Pair<String, FloatArray>> {
|
||||||
|
ensureSchema()
|
||||||
|
conn.prepareStatement(
|
||||||
|
"SELECT id, embedding FROM memory_note_meta"
|
||||||
|
).use { ps ->
|
||||||
|
ps.executeQuery().use { rs ->
|
||||||
|
val out = ArrayList<Pair<String, FloatArray>>()
|
||||||
|
while (rs.next()) {
|
||||||
|
val id = rs.getString("id")
|
||||||
|
val blob = rs.getBytes("embedding") ?: continue
|
||||||
|
out.add(id to blob.toFloatArray(dimension))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun list(category: MemoryCategory?, conversationId: String?, limit: Int, offset: Int): List<MemoryNote> {
|
||||||
|
ensureSchema()
|
||||||
|
val where = buildString {
|
||||||
|
val clauses = mutableListOf<String>()
|
||||||
|
if (category != null) clauses += "category = ?"
|
||||||
|
if (conversationId != null) clauses += "conversation_id = ?"
|
||||||
|
if (clauses.isNotEmpty()) append("WHERE ").append(clauses.joinToString(" AND "))
|
||||||
|
}
|
||||||
|
val sql = "SELECT id, category, content, created_at, last_used_at, use_count, conversation_id, source FROM memory_note_meta $where ORDER BY last_used_at DESC LIMIT ? OFFSET ?"
|
||||||
|
return conn.prepareStatement(sql).use { ps ->
|
||||||
|
var idx = 1
|
||||||
|
if (category != null) ps.setString(idx++, category.id)
|
||||||
|
if (conversationId != null) ps.setString(idx++, conversationId)
|
||||||
|
ps.setInt(idx++, limit)
|
||||||
|
ps.setInt(idx, offset)
|
||||||
|
ps.executeQuery().use { rs ->
|
||||||
|
buildList {
|
||||||
|
while (rs.next()) add(rs.toNote(rs.getString("id")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(id: String): Boolean {
|
||||||
|
ensureSchema()
|
||||||
|
return conn.prepareStatement("DELETE FROM memory_note_meta WHERE id = ?").use { ps ->
|
||||||
|
ps.setString(1, id)
|
||||||
|
ps.executeUpdate() > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markUsed(id: String, at: Instant) {
|
||||||
|
ensureSchema()
|
||||||
|
conn.prepareStatement(
|
||||||
|
"UPDATE memory_note_meta SET use_count = use_count + 1, last_used_at = ? WHERE id = ?"
|
||||||
|
).use { ps ->
|
||||||
|
ps.setLong(1, at.toEpochMilliseconds())
|
||||||
|
ps.setString(2, id)
|
||||||
|
ps.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Default `now` для тестов. */
|
||||||
|
internal fun now(): Instant = Clock.System.now()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает (или создаёт) SQLite-БД по пути [dbPath], инициализирует
|
||||||
|
* схему `memory_note_meta` и возвращает [SqliteMemoryMetaStore].
|
||||||
|
*/
|
||||||
|
fun open(dbPath: String, dimension: Int): SqliteMemoryMetaStore {
|
||||||
|
val conn = DriverManager.getConnection("jdbc:sqlite:$dbPath")
|
||||||
|
return SqliteMemoryMetaStore(conn, dimension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultSet.toNote(id: String): MemoryNote {
|
||||||
|
val catId = getString("category")
|
||||||
|
val srcId = getString("source")
|
||||||
|
val createdMs = getLong("created_at")
|
||||||
|
val lastUsedMs = getLong("last_used_at")
|
||||||
|
return MemoryNote(
|
||||||
|
id = id,
|
||||||
|
category = MemoryCategory.fromId(catId),
|
||||||
|
content = getString("content"),
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(createdMs),
|
||||||
|
lastUsedAt = Instant.fromEpochMilliseconds(lastUsedMs),
|
||||||
|
useCount = getInt("use_count"),
|
||||||
|
conversationId = getString("conversation_id"),
|
||||||
|
source = MemorySource.fromId(srcId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Little-endian packed Float32Array → byte[].
|
||||||
|
* JVector ожидает packed float, а JVM по умолчанию big-endian — переставляем явно.
|
||||||
|
*/
|
||||||
|
internal fun FloatArray.toLittleEndianBytes(): ByteArray {
|
||||||
|
val bb = ByteBuffer.allocate(size * 4).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
bb.asFloatBuffer().put(this)
|
||||||
|
return bb.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обратное преобразование: byte[] → FloatArray (little-endian → JVM-native).
|
||||||
|
* Проверяет длину против [expectedDim].
|
||||||
|
*/
|
||||||
|
internal fun ByteArray.toFloatArray(expectedDim: Int): FloatArray {
|
||||||
|
require(size == expectedDim * 4) {
|
||||||
|
"blob size $size != expected ${expectedDim * 4} bytes (dim=$expectedDim)"
|
||||||
|
}
|
||||||
|
val bb = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
val out = FloatArray(expectedDim)
|
||||||
|
bb.asFloatBuffer().get(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemorySystem
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бандл компонентов vector-бэкенда памяти — то же, что
|
||||||
|
* [pw.binom.agentik.memory.md.MdMemorySystem], но на базе JVector + SQLite + LLM-эмбеддингов.
|
||||||
|
*
|
||||||
|
* Содержит:
|
||||||
|
* - [store] — `MemoryStore` (vector-backed)
|
||||||
|
* - [prefetcher] — top-K через vector search + `markUsed`
|
||||||
|
* - [reviewer] — простая эвристика (vector-рекомендации оставим для Phase 5 LlmMemoryReviewer)
|
||||||
|
*
|
||||||
|
* Закрытие через [close] освобождает SQLite-коннекшен и (если есть) HTTP-клиент эмбеддингов.
|
||||||
|
*/
|
||||||
|
class VectorMemorySystem(
|
||||||
|
override val store: MemoryStore,
|
||||||
|
override val prefetcher: MemoryPrefetcher,
|
||||||
|
override val reviewer: MemoryReviewer,
|
||||||
|
private val closables: List<AutoCloseable>,
|
||||||
|
) : MemorySystem {
|
||||||
|
override fun close() {
|
||||||
|
closables.forEach { runCatching { it.close() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
|
||||||
|
*
|
||||||
|
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
|
||||||
|
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient
|
||||||
|
* @param topK размер top-K для prefetch
|
||||||
|
*/
|
||||||
|
fun open(
|
||||||
|
dbPath: String,
|
||||||
|
embedding: EmbeddingProvider,
|
||||||
|
topK: Int = 10,
|
||||||
|
): VectorMemorySystem {
|
||||||
|
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension)
|
||||||
|
// Граф пересобирается из SQLite (источник правды): без seed'ов
|
||||||
|
// после рестарта in-RAM индекс пуст и search возвращал бы [],
|
||||||
|
// пока не появятся новые upsert'ы.
|
||||||
|
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries())
|
||||||
|
val store = VectorMemoryStore(index, metaStore, embedding)
|
||||||
|
val prefetcher = VectorPrefetcher(store, topK)
|
||||||
|
val reviewer = VectorMemoryReviewer(store)
|
||||||
|
return VectorMemorySystem(
|
||||||
|
store = store,
|
||||||
|
prefetcher = prefetcher,
|
||||||
|
reviewer = reviewer,
|
||||||
|
closables = listOfNotNull(
|
||||||
|
metaStore,
|
||||||
|
index,
|
||||||
|
embedding as? AutoCloseable,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `MemoryPrefetcher` поверх vector-store: top-K через cosine similarity + recency re-rank.
|
||||||
|
* На каждом результате вызывает `store.markUsed(id)`.
|
||||||
|
*/
|
||||||
|
class VectorPrefetcher(
|
||||||
|
private val store: MemoryStore,
|
||||||
|
private val defaultTopK: Int,
|
||||||
|
) : MemoryPrefetcher {
|
||||||
|
override suspend fun prefetch(
|
||||||
|
query: String,
|
||||||
|
topK: Int,
|
||||||
|
category: MemoryCategory?,
|
||||||
|
): List<MemoryNote> {
|
||||||
|
val results = store.search(
|
||||||
|
MemorySearchQuery(
|
||||||
|
query = query,
|
||||||
|
topK = topK.takeIf { it > 0 } ?: defaultTopK,
|
||||||
|
category = category,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
results.forEach { store.markUsed(it.note.id) }
|
||||||
|
return results.map { it.note }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простейший reviewer для vector-бэкенда: не извлекает новых фактов из ходов,
|
||||||
|
* только дедуплицирует/маркирует использованные. Для настоящего LLM-driven review'а
|
||||||
|
* (Hermes-style one-shot с whitelist tools) см. Phase 5 — [LlmMemoryReviewer].
|
||||||
|
*/
|
||||||
|
class VectorMemoryReviewer(
|
||||||
|
private val store: MemoryStore,
|
||||||
|
) : MemoryReviewer {
|
||||||
|
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
|
||||||
|
MemoryReviewDecision()
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
package pw.binom.agentik.memory.vector.embedding
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.HttpRequest
|
||||||
|
import java.net.http.HttpResponse
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
|
||||||
|
* Используется при memory-backend=vector.
|
||||||
|
*
|
||||||
|
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов
|
||||||
|
* к API на одинаковых промптах.
|
||||||
|
*
|
||||||
|
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
|
||||||
|
* @param apiKey bearer-токен
|
||||||
|
* @param model имя модели эмбеддингов, например `text-embedding-3-small`
|
||||||
|
* @param dimension размерность вектора (по умолчанию 1536 — text-embedding-3-small)
|
||||||
|
* @param cacheSize ёмкость LRU-кэша (default 256)
|
||||||
|
*/
|
||||||
|
class HttpEmbeddingClient(
|
||||||
|
private val apiUrl: String,
|
||||||
|
private val apiKey: String,
|
||||||
|
private val model: String,
|
||||||
|
override val dimension: Int,
|
||||||
|
cacheSize: Int = 256,
|
||||||
|
) : EmbeddingProvider, AutoCloseable {
|
||||||
|
|
||||||
|
private val cache = LruCache<String, FloatArray>(cacheSize)
|
||||||
|
private val http: HttpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override suspend fun embed(text: String): FloatArray {
|
||||||
|
cache.get(text)?.let { return it }
|
||||||
|
val vector = fetchEmbedding(text)
|
||||||
|
cache.put(text, vector)
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchEmbedding(text: String): FloatArray {
|
||||||
|
val url = URI.create("$apiUrl/v1/embeddings")
|
||||||
|
val body = buildJsonObject {
|
||||||
|
put("model", JsonPrimitive(model))
|
||||||
|
put("input", JsonPrimitive(text))
|
||||||
|
}.toString()
|
||||||
|
val request = HttpRequest.newBuilder(url)
|
||||||
|
.header("Authorization", "Bearer $apiKey")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.build()
|
||||||
|
val response = http.send(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
if (response.statusCode() !in 200..299) {
|
||||||
|
error("embedding API error ${response.statusCode()}: ${response.body()}")
|
||||||
|
}
|
||||||
|
val parsed = json.parseToJsonElement(response.body()).jsonObject
|
||||||
|
val data = parsed["data"]?.jsonArray ?: error("missing 'data' in embedding response")
|
||||||
|
val firstData = data[0].jsonObject
|
||||||
|
val embeddingArray = firstData["embedding"]?.jsonArray ?: error("missing 'embedding' array")
|
||||||
|
val out = FloatArray(embeddingArray.size)
|
||||||
|
for ((i, v: JsonElement) in embeddingArray.withIndex()) {
|
||||||
|
out[i] = v.jsonPrimitive.content.toFloat()
|
||||||
|
}
|
||||||
|
require(out.size == dimension) {
|
||||||
|
"embedding dim mismatch: got ${out.size}, expected $dimension (model=$model)"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = http.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LruCache<K, V>(private val capacity: Int) {
|
||||||
|
private val map = LinkedHashMap<K, V>(capacity, 0.75f, true)
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
fun get(key: K): V? = synchronized(lock) {
|
||||||
|
map[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(key: K, value: V) = synchronized(lock) {
|
||||||
|
map[key] = value
|
||||||
|
if (map.size > capacity) {
|
||||||
|
val firstKey = map.keys.iterator().next()
|
||||||
|
map.remove(firstKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package pw.binom.agentik.memory.vector.embedding
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
|
||||||
|
*
|
||||||
|
* Особенности:
|
||||||
|
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU),
|
||||||
|
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать
|
||||||
|
* доступ из нескольких корутин (ONNX-сессия не reentrant).
|
||||||
|
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр
|
||||||
|
* `dimension` в конструкторе не принимаем — берём через [probeDimension].
|
||||||
|
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на
|
||||||
|
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется —
|
||||||
|
* легко добавить.
|
||||||
|
*
|
||||||
|
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
|
||||||
|
* Скачать: см. README репы `text-embedding-kmp`.
|
||||||
|
*/
|
||||||
|
class SiglipEmbeddingProvider(
|
||||||
|
modelPath: String,
|
||||||
|
tokenizerPath: String,
|
||||||
|
) : EmbeddingProvider, AutoCloseable {
|
||||||
|
|
||||||
|
private val extractor: TextEmbeddingExtractor =
|
||||||
|
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
||||||
|
|
||||||
|
override val dimension: Int = run {
|
||||||
|
val probe = extractor.embed("probe")
|
||||||
|
probe.dim
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock { extractor.embed(text).values }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
extractor.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class JVectorMemoryIndexTest {
|
||||||
|
|
||||||
|
private fun makeVec(seed: Int, dim: Int): FloatArray {
|
||||||
|
val v = FloatArray(dim)
|
||||||
|
var s = seed.toLong() and 0xFFFFFFFFL
|
||||||
|
for (i in 0 until dim) {
|
||||||
|
s = (s * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||||
|
v[i] = ((s.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||||
|
}
|
||||||
|
var norm = 0f
|
||||||
|
for (x in v) norm += x * x
|
||||||
|
norm = kotlin.math.sqrt(norm)
|
||||||
|
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptySearchReturnsEmpty() = runTest {
|
||||||
|
val idx = JVectorMemoryIndex(dimension = 8, seedEntries = emptyList())
|
||||||
|
val out = idx.search(makeVec(1, 8), k = 5) { true }
|
||||||
|
assertTrue(out.isEmpty())
|
||||||
|
idx.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addAndSearchReturnsNearest() = runTest {
|
||||||
|
val dim = 32
|
||||||
|
val idx = JVectorMemoryIndex(dimension = dim, seedEntries = emptyList())
|
||||||
|
// 50 случайных векторов, id'ы = "v0".."v49"
|
||||||
|
for (i in 0 until 50) {
|
||||||
|
idx.add("v$i", makeVec(i + 100, dim))
|
||||||
|
}
|
||||||
|
assertEquals(50L, idx.size())
|
||||||
|
// Запрос = vec с seed 105 (= v5)
|
||||||
|
val results = idx.search(makeVec(105, dim), k = 5) { true }
|
||||||
|
assertEquals(5, results.size)
|
||||||
|
// v5 должен быть среди top-k (топовый результат должен быть тем же seed'ом).
|
||||||
|
assertEquals("v5", results.first().id)
|
||||||
|
idx.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeHidesFromSearch() = runTest {
|
||||||
|
val dim = 16
|
||||||
|
val idx = JVectorMemoryIndex(dimension = dim, seedEntries = emptyList())
|
||||||
|
for (i in 0 until 10) {
|
||||||
|
idx.add("n$i", makeVec(i, dim))
|
||||||
|
}
|
||||||
|
assertTrue(idx.remove("n3"))
|
||||||
|
val results = idx.search(makeVec(3, dim), k = 10) { true }
|
||||||
|
assertEquals(9, results.size)
|
||||||
|
assertTrue(results.none { it.id == "n3" })
|
||||||
|
idx.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reAddReusesOrdinal() = runTest {
|
||||||
|
val dim = 8
|
||||||
|
val idx = JVectorMemoryIndex(dimension = dim, seedEntries = emptyList())
|
||||||
|
idx.add("x", makeVec(1, dim))
|
||||||
|
idx.add("x", makeVec(2, dim)) // overwrite
|
||||||
|
assertEquals(1L, idx.size())
|
||||||
|
idx.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import java.io.File
|
||||||
|
import java.sql.DriverManager
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class SqliteMemoryMetaStoreTest {
|
||||||
|
|
||||||
|
private lateinit var file: File
|
||||||
|
private lateinit var store: SqliteMemoryMetaStore
|
||||||
|
private val dim = 32
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
file = File.createTempFile("agentik-vec-test-", ".db").also { it.deleteOnExit() }
|
||||||
|
store = SqliteMemoryMetaStore(
|
||||||
|
"jdbc:sqlite:${file.absolutePath}",
|
||||||
|
dimension = dim,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeNote(id: String, content: String, cat: MemoryCategory = MemoryCategory.WORLD): MemoryNote {
|
||||||
|
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||||
|
return MemoryNote(
|
||||||
|
id = id,
|
||||||
|
category = cat,
|
||||||
|
content = content,
|
||||||
|
createdAt = now,
|
||||||
|
lastUsedAt = now,
|
||||||
|
useCount = 0,
|
||||||
|
conversationId = null,
|
||||||
|
source = MemorySource.USER_EXPLICIT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeVec(seed: Int): FloatArray {
|
||||||
|
val v = FloatArray(dim)
|
||||||
|
var s = seed.toLong() and 0xFFFFFFFFL
|
||||||
|
for (i in 0 until dim) {
|
||||||
|
s = (s * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||||
|
v[i] = ((s.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putAndGetRoundTrip() {
|
||||||
|
val note = makeNote("n1", "hello world")
|
||||||
|
val vec = makeVec(42)
|
||||||
|
store.put(note, vec)
|
||||||
|
val got = store.get("n1")
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals("hello world", got.content)
|
||||||
|
assertEquals(MemoryCategory.WORLD, got.category)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allEntriesReturnsAll() {
|
||||||
|
repeat(5) { i ->
|
||||||
|
store.put(makeNote("n$i", "text $i"), makeVec(i))
|
||||||
|
}
|
||||||
|
val all = store.allEntries()
|
||||||
|
assertEquals(5, all.size)
|
||||||
|
assertEquals(setOf("n0", "n1", "n2", "n3", "n4"), all.map { it.first }.toSet())
|
||||||
|
all.forEach { (_, v) ->
|
||||||
|
assertEquals(dim, v.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listFiltersByCategory() {
|
||||||
|
store.put(makeNote("w1", "world 1", MemoryCategory.WORLD), makeVec(1))
|
||||||
|
store.put(makeNote("u1", "user 1", MemoryCategory.USER), makeVec(2))
|
||||||
|
store.put(makeNote("w2", "world 2", MemoryCategory.WORLD), makeVec(3))
|
||||||
|
|
||||||
|
val worlds = store.list(category = MemoryCategory.WORLD, conversationId = null, limit = 10, offset = 0)
|
||||||
|
assertEquals(2, worlds.size)
|
||||||
|
assertTrue(worlds.all { it.category == MemoryCategory.WORLD })
|
||||||
|
|
||||||
|
val users = store.list(category = MemoryCategory.USER, conversationId = null, limit = 10, offset = 0)
|
||||||
|
assertEquals(1, users.size)
|
||||||
|
assertEquals("u1", users.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesNote() {
|
||||||
|
store.put(makeNote("x", "to delete"), makeVec(7))
|
||||||
|
assertTrue(store.delete("x"))
|
||||||
|
assertNull(store.get("x"))
|
||||||
|
assertTrue(store.allEntries().isEmpty())
|
||||||
|
// Второй delete возвращает false.
|
||||||
|
assertEquals(false, store.delete("x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markUsedIncrementsCount() {
|
||||||
|
val note = makeNote("y", "used")
|
||||||
|
store.put(note, makeVec(8))
|
||||||
|
store.markUsed("y", Instant.fromEpochMilliseconds(1000L))
|
||||||
|
store.markUsed("y", Instant.fromEpochMilliseconds(2000L))
|
||||||
|
val got = store.get("y")
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals(2, got.useCount)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(2000L), got.lastUsedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun embeddingsAreLittleEndian() {
|
||||||
|
// Проверяем что BLOB читается в little-endian: первая 4 байта = first float.
|
||||||
|
// dim=32 => vec длиной 32.
|
||||||
|
val vec = FloatArray(dim) { i -> (i + 1).toFloat() }
|
||||||
|
val note = makeNote("le", "le test")
|
||||||
|
store.put(note, vec)
|
||||||
|
val rawBytes = DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { conn ->
|
||||||
|
conn.prepareStatement("SELECT embedding FROM memory_note_meta WHERE id = ?").use { ps ->
|
||||||
|
ps.setString(1, "le")
|
||||||
|
ps.executeQuery().use { rs ->
|
||||||
|
rs.next()
|
||||||
|
rs.getBytes("embedding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// В little-endian IEEE-754: 1.0f = 0x00 0x00 0x80 0x3F (младший байт первый).
|
||||||
|
assertEquals(0x00.toByte(), rawBytes[0])
|
||||||
|
assertEquals(0x00.toByte(), rawBytes[1])
|
||||||
|
assertEquals(0x80.toByte(), rawBytes[2])
|
||||||
|
assertEquals(0x3F.toByte(), rawBytes[3])
|
||||||
|
// 2.0f = 0x00 0x00 0x00 0x40
|
||||||
|
assertEquals(0x00.toByte(), rawBytes[4])
|
||||||
|
assertEquals(0x00.toByte(), rawBytes[5])
|
||||||
|
assertEquals(0x00.toByte(), rawBytes[6])
|
||||||
|
assertEquals(0x40.toByte(), rawBytes[7])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopenKeepsData() {
|
||||||
|
store.put(makeNote("persistent", "survives restart"), makeVec(99))
|
||||||
|
store.close()
|
||||||
|
// Переоткрываем тот же файл — данные должны быть.
|
||||||
|
store = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
||||||
|
val got = store.get("persistent")
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals("survives restart", got.content)
|
||||||
|
val entries = store.allEntries()
|
||||||
|
assertEquals(1, entries.size)
|
||||||
|
assertEquals(dim, entries[0].second.size)
|
||||||
|
// Round-trip работает (byte-order little-endian — проверено в отдельном тесте).
|
||||||
|
// Здесь просто убеждаемся что BLOB распарсился в массив нужной длины.
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class VectorMemoryStoreTest {
|
||||||
|
|
||||||
|
private lateinit var file: File
|
||||||
|
private lateinit var metaStore: SqliteMemoryMetaStore
|
||||||
|
private lateinit var index: JVectorMemoryIndex
|
||||||
|
private lateinit var store: VectorMemoryStore
|
||||||
|
private val dim = 16
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
file = File.createTempFile("agentik-vms-test-", ".db").also { it.deleteOnExit() }
|
||||||
|
metaStore = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
||||||
|
// Загружаем начальные entries из metaStore (на случай если что-то там есть).
|
||||||
|
val seedEntries = metaStore.allEntries()
|
||||||
|
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||||
|
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim))
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeNote(id: String, content: String, cat: MemoryCategory = MemoryCategory.WORLD): MemoryNote {
|
||||||
|
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||||
|
return MemoryNote(
|
||||||
|
id = id,
|
||||||
|
category = cat,
|
||||||
|
content = content,
|
||||||
|
createdAt = now,
|
||||||
|
lastUsedAt = now,
|
||||||
|
useCount = 0,
|
||||||
|
conversationId = null,
|
||||||
|
source = MemorySource.USER_EXPLICIT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upsertAndGet() = runTest {
|
||||||
|
val note = makeNote("a", "alpha")
|
||||||
|
store.upsert(note)
|
||||||
|
val got = store.get("a")
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals("alpha", got.content)
|
||||||
|
assertEquals(1L, index.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchFindsNearest() = runTest {
|
||||||
|
// Несколько заметок; запрос — близкий к "hello world" по семантике.
|
||||||
|
store.upsert(makeNote("a", "kotlin coroutines async"))
|
||||||
|
store.upsert(makeNote("b", "java virtual machine"))
|
||||||
|
store.upsert(makeNote("c", "the quick brown fox"))
|
||||||
|
store.upsert(makeNote("d", "asynchronous programming paradigms"))
|
||||||
|
|
||||||
|
val results = store.search(MemorySearchQuery(query = "kotlin async programming", topK = 3))
|
||||||
|
assertTrue(results.isNotEmpty())
|
||||||
|
assertTrue(results.size <= 3)
|
||||||
|
// Сортировка descending — первый score >= последнего.
|
||||||
|
if (results.size >= 2) {
|
||||||
|
assertTrue(results[0].score >= results.last().score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchFiltersByCategory() = runTest {
|
||||||
|
store.upsert(makeNote("w1", "world thing 1", MemoryCategory.WORLD))
|
||||||
|
store.upsert(makeNote("u1", "user thing 1", MemoryCategory.USER))
|
||||||
|
store.upsert(makeNote("w2", "world thing 2", MemoryCategory.WORLD))
|
||||||
|
|
||||||
|
val worldResults = store.search(
|
||||||
|
MemorySearchQuery(query = "thing", topK = 10, category = MemoryCategory.WORLD)
|
||||||
|
)
|
||||||
|
assertTrue(worldResults.isNotEmpty())
|
||||||
|
assertTrue(worldResults.all { it.note.category == MemoryCategory.WORLD })
|
||||||
|
// user заметка не должна попасть в результат даже если она "ближе" по эмбеддингу.
|
||||||
|
assertTrue(worldResults.none { it.note.id == "u1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesBoth() = runTest {
|
||||||
|
store.upsert(makeNote("x", "to delete"))
|
||||||
|
assertEquals(1L, index.size())
|
||||||
|
assertTrue(store.delete("x"))
|
||||||
|
assertNull(store.get("x"))
|
||||||
|
assertEquals(0L, index.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upsertEmitsEvent() = runTest {
|
||||||
|
// MutableSharedFlow без replay: подписчик должен быть ДО emit.
|
||||||
|
// backgroundScope — это TestScope'овый scope, авто-отменяется при teardown.
|
||||||
|
val received = kotlinx.coroutines.CompletableDeferred<pw.binom.agentik.memory.MemoryStoreEvent>()
|
||||||
|
backgroundScope.launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
store.events().collect { received.complete(it); return@collect }
|
||||||
|
}
|
||||||
|
store.upsert(makeNote("e", "eventful"))
|
||||||
|
val ev = withTimeout(1000) { received.await() }
|
||||||
|
assertEquals("e", (ev as pw.binom.agentik.memory.MemoryStoreEvent.Upserted).note.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopenReconstructsIndexFromSqlite() = runTest {
|
||||||
|
store.upsert(makeNote("p", "persistent 1"))
|
||||||
|
store.upsert(makeNote("q", "persistent 2"))
|
||||||
|
// Close → re-open.
|
||||||
|
store.close()
|
||||||
|
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
||||||
|
val seedEntries = meta2.allEntries()
|
||||||
|
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||||
|
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim))
|
||||||
|
try {
|
||||||
|
assertEquals(2L, idx2.size())
|
||||||
|
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
|
||||||
|
assertTrue(results.any { it.note.id == "p" })
|
||||||
|
} finally {
|
||||||
|
store2.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
|
||||||
|
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
|
||||||
|
// из SQLite — иначе после рестарта search возвращает [] до первого upsert.
|
||||||
|
val first = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
||||||
|
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
|
||||||
|
first.close()
|
||||||
|
|
||||||
|
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
||||||
|
try {
|
||||||
|
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
|
||||||
|
assertTrue(results.any { it.note.id == "r" })
|
||||||
|
} finally {
|
||||||
|
second.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package pw.binom.agentik.memory.vector.embedding
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke-test SiglipEmbeddingProvider.
|
||||||
|
*
|
||||||
|
* Если файлы модели не найдены (по дефолту `/tmp/text-emb-model/`),
|
||||||
|
* тест пропускается через [assumeModelAvailable]. Если найдены —
|
||||||
|
* проверяется, что провайдер открывается, возвращает валидный эмбеддинг
|
||||||
|
* правильной размерности, и закрывается чисто.
|
||||||
|
*/
|
||||||
|
class SiglipEmbeddingProviderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dimension is 768 when model loads successfully`() {
|
||||||
|
val modelDir = File("/tmp/text-emb-model")
|
||||||
|
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
|
||||||
|
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
|
||||||
|
}
|
||||||
|
SiglipEmbeddingProvider(
|
||||||
|
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
|
||||||
|
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
|
||||||
|
).use { provider ->
|
||||||
|
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
|
||||||
|
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") }
|
||||||
|
assertEquals(768, v.size)
|
||||||
|
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing model file fails with clear error`() {
|
||||||
|
val tmpDir = kotlin.io.path.createTempDirectory(prefix = "no-model-").toFile()
|
||||||
|
val nonExistent = File(tmpDir, "does-not-exist.onnx")
|
||||||
|
assertFailsWith<Exception> {
|
||||||
|
SiglipEmbeddingProvider(
|
||||||
|
modelPath = nonExistent.absolutePath,
|
||||||
|
tokenizerPath = nonExistent.absolutePath,
|
||||||
|
).use { it.dimension }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun assume(condition: Boolean, message: () -> String) {
|
||||||
|
org.junit.Assume.assumeTrue(message(), condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# `:proto` — протокол общения с агентом (KMP, jvm + native)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Типы и контракт in-house протокола `agentik`, заменившего AG-UI:
|
||||||
|
|
||||||
|
- **stateful** — сервер сам владеет диалогом; клиент шлёт только новые
|
||||||
|
сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан
|
||||||
|
повторять `messages[]` каждый раз).
|
||||||
|
- **declarative история vs. события** — `Message` это то, что уже легло
|
||||||
|
в БД, `Event` это live-стрим от агента во время `send()` или `events()`.
|
||||||
|
- **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри
|
||||||
|
`:proto`; это контракт.
|
||||||
|
|
||||||
|
Решает проблему: AG-UI клиент вынужден каждый раз знать и пересобирать
|
||||||
|
полную историю, а его серверная часть (`AbstractAgent`) — постоянно
|
||||||
|
сериализовать-десериализовать всю переписку. В `:proto` сервер один,
|
||||||
|
контракт тонкий, переписка персистится нативно (SQLite, файлы, что
|
||||||
|
хотите). Можно подменить front-end или back-end, протокол остаётся.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:server` — Ktor-фасад, маппит `Agent` ↔ HTTP/SSE.
|
||||||
|
- `:client` — Ktor-клиент, маппит HTTP/SSE ↔ `Agent/Conversation`.
|
||||||
|
- `:agentik-cli`, `:agentik-tui` — оба работают поверх `:client`,
|
||||||
|
а следовательно поверх `:proto`.
|
||||||
|
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
|
||||||
|
`Message`/`Event` напрямую через storage.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
kotlin {
|
||||||
|
sourceSets.commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:proto:0.1.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Артефакт `pw.binom.agentik:proto:0.1.0` живёт в Nexus-репозитории
|
||||||
|
`caffeine` (HTTP `http://<your-nexus>/repository/caffeine/`, plain-HTTP,
|
||||||
|
credentials — через переменные `binom.repo.user/password/url`).
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
Каталог `gradle/libs.versions.toml`, секция `[versions]` → `agentik-proto`.
|
||||||
|
Поднять версию → переопубликовать все KMP-таргеты через `./gradlew
|
||||||
|
:proto:publish -Pversion=...` (или триггернуть Gitea release).
|
||||||
|
|
||||||
|
Текущие KMP-таргеты: `jvm + macosX64/macosArm64 +
|
||||||
|
iosX64/iosArm64/iosSimulatorArm64 + linuxX64/linuxArm64 + mingwX64`.
|
||||||
|
|
||||||
|
## Публикация
|
||||||
|
|
||||||
|
Настройки в `gradle.properties` / env: `binom.repo.url`, `binom.repo.user`,
|
||||||
|
`binom.repo.password`. `./gradlew :proto:publish` публикует все
|
||||||
|
target-specific артефакты + общий `kotlinMultiplatform`.
|
||||||
|
|
||||||
|
## Основные типы
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface Agent {
|
||||||
|
fun id: String
|
||||||
|
suspend fun createConversation(title: String? = null): Conversation
|
||||||
|
suspend fun getConversation(id: String): Conversation?
|
||||||
|
suspend fun getConversations(offset: Int = 0): Flow<Conversation>
|
||||||
|
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation : AutoCloseable {
|
||||||
|
val id: String
|
||||||
|
val updatedAt: Instant
|
||||||
|
val isSupportImageInput: Boolean
|
||||||
|
val isSupportImageOutput: Boolean
|
||||||
|
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
|
||||||
|
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
|
||||||
|
suspend fun getMessages(offset: Int = 0): Flow<Message>
|
||||||
|
suspend fun rename(title: String): Boolean
|
||||||
|
fun interrupt()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Content {
|
||||||
|
class Text(val body: String) : Content
|
||||||
|
class Image(val data: ByteArray, val mime: String) : Content
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Message {
|
||||||
|
val id: String
|
||||||
|
val date: Instant
|
||||||
|
interface Body : Message { val content: List<Content> }
|
||||||
|
interface System : Message
|
||||||
|
class UserMessage(...) : Body
|
||||||
|
class AssistantMessage(...) : Body
|
||||||
|
class ToolCall(...) : System
|
||||||
|
class ToolResult(...) : System
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
enum ResponseType { TEXT, IMAGE }
|
||||||
|
class StartReasoning(...) : Event
|
||||||
|
class StartResponse(val type: ResponseType) : Event
|
||||||
|
class AppendText(val body: String) : Event
|
||||||
|
class AppendImage(val body: ByteArray, val mime: String) : Event
|
||||||
|
class End(...) : Event
|
||||||
|
class Interrupted(...) : Event
|
||||||
|
class Error(val message: String, val code: Int? = null) : Event
|
||||||
|
class ToolCall(...) : Event
|
||||||
|
class ToolResult(...) : Event
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
||||||
|
и `:client`.
|
||||||
|
- Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
|
||||||
|
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
||||||
|
внутри `:standalone` (ChatAgent) и выше.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :proto:jvmTest
|
||||||
|
./gradlew :proto:allTests # дополнительно linuxX64 (если Linux) / iosSimulator (если macOS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Иммутабельный API (после рефакторинга из
|
||||||
|
AG-UI). Возможные будущие расширения: typed tool-result, multi-modal
|
||||||
|
contents, server-pushed references — все обсуждаются через общий
|
||||||
|
[IRC-QUESTIONS.md](../IRC-QUESTIONS.md).
|
||||||
@@ -21,6 +21,8 @@ kotlin {
|
|||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
|
// для JsonElement в MessageContext.metadata
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
|||||||
@@ -37,8 +37,14 @@ interface Conversation : AutoCloseable {
|
|||||||
*
|
*
|
||||||
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
|
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
|
||||||
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
|
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
|
||||||
|
*
|
||||||
|
* @param content тело сообщения (текст/картинки).
|
||||||
|
* @param context опциональный контекст инициации хода: кто/что и почему.
|
||||||
|
* `null` = обычное user-сообщение. Используется для cron/webhook/system
|
||||||
|
* событий — модель увидит в working memory префикс
|
||||||
|
* `[origin] description (sourceId=…)` к тексту сообщения.
|
||||||
*/
|
*/
|
||||||
suspend fun send(content: List<Content>)
|
suspend fun send(content: List<Content>, context: MessageContext? = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
|
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
|
||||||
|
|||||||
@@ -20,7 +20,16 @@ sealed interface Message {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("user_message")
|
@SerialName("user_message")
|
||||||
class UserMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
|
class UserMessage(
|
||||||
|
override val id: String,
|
||||||
|
val content: List<Content>,
|
||||||
|
override val date: Instant,
|
||||||
|
/**
|
||||||
|
* Контекст инициации хода: кто/что вызвало этот turn. `null` —
|
||||||
|
* обычное user-сообщение. См. [MessageContext].
|
||||||
|
*/
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
) : Message
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("assistant_message")
|
@SerialName("assistant_message")
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кто/что инициировал данный ход сообщения.
|
||||||
|
*
|
||||||
|
* Используется в [MessageContext] — каждый ход диалога может нести
|
||||||
|
* дополнительный контекст о природе триггера:
|
||||||
|
* - [USER] — обычное сообщение от пользователя в чате (дефолт, context=null).
|
||||||
|
* - [SYSTEM] — программное системное сообщение (старт агента, режим обслуживания,
|
||||||
|
* уведомление о завершении фоновой задачи).
|
||||||
|
* - [EVENT] — внешнее событие (cron, webhook, file-changed, и т.п.).
|
||||||
|
* В этом случае [MessageContext.sourceId] и [MessageContext.description]
|
||||||
|
* позволяют модели понять, что за источник её разбудил.
|
||||||
|
*
|
||||||
|
* Семантический контракт:
|
||||||
|
* - origin != USER ⇒ [MessageContext.description] обязателен и должен быть
|
||||||
|
* человекочитаемым (короткая фраза для модели).
|
||||||
|
* - origin == USER ⇒ context может быть `null` (дефолт), и если задан — поля
|
||||||
|
* интерпретируются как «дополнительная мета» (например, ui_client).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class MessageOrigin {
|
||||||
|
@SerialName("user")
|
||||||
|
USER,
|
||||||
|
|
||||||
|
@SerialName("system")
|
||||||
|
SYSTEM,
|
||||||
|
|
||||||
|
@SerialName("event")
|
||||||
|
EVENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
|
||||||
|
*
|
||||||
|
* Примеры:
|
||||||
|
* ```
|
||||||
|
* // cron-задача утренней сводки
|
||||||
|
* MessageContext(
|
||||||
|
* origin = MessageOrigin.EVENT,
|
||||||
|
* description = "scheduled cron 'morning-briefing'",
|
||||||
|
* sourceId = "cron-42",
|
||||||
|
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // обычное сообщение из IRC
|
||||||
|
* MessageContext(
|
||||||
|
* origin = MessageOrigin.USER,
|
||||||
|
* sourceId = "irc-channel:agentik",
|
||||||
|
* description = "PRIVMSG from nick",
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // старт агента после рестарта
|
||||||
|
* MessageContext(
|
||||||
|
* origin = MessageOrigin.SYSTEM,
|
||||||
|
* description = "agent startup greeting",
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Сериализация: snake_case для стабильного wire-формата ([origin] идёт как
|
||||||
|
* `user`/`system`/`event` благодаря @SerialName на enum).
|
||||||
|
*
|
||||||
|
* Forward-совместимо: добавление новых полей — non-breaking для старых
|
||||||
|
* клиентов, которые их игнорируют.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MessageContext(
|
||||||
|
val origin: MessageOrigin,
|
||||||
|
/**
|
||||||
|
* Короткая человекочитаемая фраза для LLM: попадает в working memory
|
||||||
|
* как префикс `[origin] description (sourceId=…)` к user-сообщению,
|
||||||
|
* чтобы модель видела, что её разбудил не пользователь, а событие.
|
||||||
|
*/
|
||||||
|
val description: String? = null,
|
||||||
|
/**
|
||||||
|
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC,
|
||||||
|
* id фонового события. Помогает модели и оператору при логировании понять,
|
||||||
|
* откуда пришёл ход.
|
||||||
|
*/
|
||||||
|
val sourceId: String? = null,
|
||||||
|
/**
|
||||||
|
* Произвольный структурированный payload о событии.
|
||||||
|
* Например: `{"scheduledAt": "...", "rule": "..."}` для cron,
|
||||||
|
* или `{"headers": {...}, "ip": "..."}` для webhook.
|
||||||
|
*
|
||||||
|
* Никогда не попадает в LLM-нагрузку как сырой JSON — используется
|
||||||
|
* только для логирования и пост-аналитики.
|
||||||
|
*/
|
||||||
|
val metadata: JsonElement? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package pw.binom.agentik.proto
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты сериализации MessageOrigin/MessageContext.
|
||||||
|
*
|
||||||
|
* Гарантируем:
|
||||||
|
* 1) enum origin → snake_case discriminator (`user`/`system`/`event`);
|
||||||
|
* 2) MessageContext → стабильный JSON-формат с полями `origin`, `description`,
|
||||||
|
* `sourceId`, `metadata`;
|
||||||
|
* 3) парсинг round-trip без потерь.
|
||||||
|
*/
|
||||||
|
class MessageContextTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `USER origin serializes as user`() {
|
||||||
|
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.USER)
|
||||||
|
assertEquals("\"user\"", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SYSTEM origin serializes as system`() {
|
||||||
|
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.SYSTEM)
|
||||||
|
assertEquals("\"system\"", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EVENT origin serializes as event`() {
|
||||||
|
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.EVENT)
|
||||||
|
assertEquals("\"event\"", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `origin deserializes from snake_case`() {
|
||||||
|
assertEquals(MessageOrigin.USER, json.decodeFromString(MessageOrigin.serializer(), "\"user\""))
|
||||||
|
assertEquals(MessageOrigin.SYSTEM, json.decodeFromString(MessageOrigin.serializer(), "\"system\""))
|
||||||
|
assertEquals(MessageOrigin.EVENT, json.decodeFromString(MessageOrigin.serializer(), "\"event\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `USER context with no fields roundtrips`() {
|
||||||
|
val ctx = MessageContext(origin = MessageOrigin.USER)
|
||||||
|
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
|
||||||
|
// description/sourceId/metadata отсутствуют → не должны попасть в JSON (explicitNulls=false + defaults)
|
||||||
|
assertEquals("""{"origin":"user"}""", encoded)
|
||||||
|
val decoded = json.decodeFromString(MessageContext.serializer(), encoded)
|
||||||
|
assertEquals(ctx, decoded)
|
||||||
|
assertNull(decoded.description)
|
||||||
|
assertNull(decoded.sourceId)
|
||||||
|
assertNull(decoded.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EVENT context with all fields roundtrips`() {
|
||||||
|
val ctx = MessageContext(
|
||||||
|
origin = MessageOrigin.EVENT,
|
||||||
|
description = "scheduled cron morning-briefing",
|
||||||
|
sourceId = "cron-42",
|
||||||
|
metadata = buildJsonObject {
|
||||||
|
put("scheduledAt", JsonPrimitive("2026-09-14T08:00:00Z"))
|
||||||
|
put("rule", JsonPrimitive("0 8 * * *"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
|
||||||
|
val decoded = json.decodeFromString(MessageContext.serializer(), encoded)
|
||||||
|
assertEquals(ctx, decoded)
|
||||||
|
assertEquals(MessageOrigin.EVENT, decoded.origin)
|
||||||
|
assertEquals("scheduled cron morning-briefing", decoded.description)
|
||||||
|
assertEquals("cron-42", decoded.sourceId)
|
||||||
|
assertEquals(ctx.metadata, decoded.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlin.experimental.ExperimentalNativeApi
|
||||||
|
@Test
|
||||||
|
fun `origin field name is origin in JSON`() {
|
||||||
|
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "boot")
|
||||||
|
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
|
||||||
|
// Поле должно называться ровно `origin` — клиенты могут на него полагаться.
|
||||||
|
assert(encoded.contains("\"origin\":\"system\"")) { "expected origin field, got: $encoded" }
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Создаёт stub-артефакт `pw.binom.voice.embeddingtext:api:2.0.0-SNAPSHOT`,
|
||||||
|
# ссылающийся на `api-jvm:2.0.0-SNAPSHOT`. Нужно из-за бага в upstream module
|
||||||
|
# metadata у siglip-jvm (ссылается на `api` без variant).
|
||||||
|
#
|
||||||
|
# Удалить, когда upstream починит module-metadata.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
M2="${HOME}/.m2/repository"
|
||||||
|
GROUP_DIR="${M2}/pw/binom/ai/embeddingtext"
|
||||||
|
SRC_DIR="${GROUP_DIR}/api-jvm/3.0.0-SNAPSHOT"
|
||||||
|
DST_DIR="${GROUP_DIR}/api/3.0.0-SNAPSHOT"
|
||||||
|
|
||||||
|
if [ ! -f "${SRC_DIR}/api-jvm-3.0.0-SNAPSHOT.jar" ]; then
|
||||||
|
echo "Source api-jvm not found: ${SRC_DIR}"
|
||||||
|
echo "Сначала опубликуй text-embedding-kmp в mavenLocal:"
|
||||||
|
echo " git clone https://git.binom.pw/subochev/text-embedding-kmp /tmp/text-embedding-kmp"
|
||||||
|
echo " cd /tmp/text-embedding-kmp && ./gradlew -Pversion=3.0.0-SNAPSHOT :api:publishJvmPublicationToMavenLocal :siglip:publishJvmPublicationToMavenLocal"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${DST_DIR}"
|
||||||
|
cp "${SRC_DIR}/api-jvm-3.0.0-SNAPSHOT.jar" "${DST_DIR}/api-3.0.0-SNAPSHOT.jar"
|
||||||
|
cp "${SRC_DIR}/api-jvm-3.0.0-SNAPSHOT-sources.jar" "${DST_DIR}/api-3.0.0-SNAPSHOT-sources.jar" 2>/dev/null || true
|
||||||
|
|
||||||
|
cat > "${DST_DIR}/api-3.0.0-SNAPSHOT.pom" <<POM
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>pw.binom.ai.embeddingtext</groupId>
|
||||||
|
<artifactId>api</artifactId>
|
||||||
|
<version>3.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
</project>
|
||||||
|
POM
|
||||||
|
|
||||||
|
cat > "${DST_DIR}/maven-metadata-local.xml" <<META
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata>
|
||||||
|
<groupId>pw.binom.ai.embeddingtext</groupId>
|
||||||
|
<artifactId>api</artifactId>
|
||||||
|
<version>3.0.0-SNAPSHOT</version>
|
||||||
|
<versioning>
|
||||||
|
<snapshot><timestamp>20260101.000000</timestamp></snapshot>
|
||||||
|
<lastUpdated>20260101000000</lastUpdated>
|
||||||
|
</versioning>
|
||||||
|
</metadata>
|
||||||
|
META
|
||||||
|
|
||||||
|
echo "Installed stub: pw.binom.ai.embeddingtext:api:3.0.0-SNAPSHOT -> ${DST_DIR}"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# `:server` — HTTP/SSE фасад для `:proto` (KMP, JVM-only)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Ktor-маршрут, экспонирующий `Agent` из `:proto` в виде JSON-API:
|
||||||
|
`POST /agentik/conversations`, `POST /agentik/conversations/:id/send`,
|
||||||
|
`GET /agentik/conversations/:id/events` (SSE), `GET /health`,
|
||||||
|
`GET /agentik/conversations`.
|
||||||
|
|
||||||
|
- **stateful** — сервер не принимает полную историю, только новые
|
||||||
|
сообщения. История хранится там, где развёрнут `Agent`.
|
||||||
|
- **декларативно** — `interface Agent` → HTTP; никакой магии, никаких
|
||||||
|
обёрток. Контракт и сериализация — тоже декларативные (kotlinx-json
|
||||||
|
с snake_case-дискриминаторами).
|
||||||
|
|
||||||
|
Решает: позволяет собрать любой собственный front-end (CLI/TUI/Web/
|
||||||
|
IRC/MCP) общаясь с одним сервером по стабильному wire-контракту.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` подключает `Route.agentikAgent(agent)` в свой
|
||||||
|
embedded Netty engine.
|
||||||
|
- Любые клиенты (наши `:client`, `:agentik-cli`, `:agentik-tui`, или
|
||||||
|
внешние web-фронтенды) идут через этот контракт.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts (KMP JVM target)
|
||||||
|
plugins { id("pw.binom.agentik.server-conventions") version "0.1.0" }
|
||||||
|
dependencies {
|
||||||
|
api("pw.binom.agentik:server:0.1.0")
|
||||||
|
api("pw.binom.agentik:proto:0.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ваш код:
|
||||||
|
fun Application.module(agent: Agent) {
|
||||||
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
|
install(SSE)
|
||||||
|
routing {
|
||||||
|
route("/agentik") { agentikAgent(agent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-server`.
|
||||||
|
|
||||||
|
## Эндпоинты (path по умолчанию `/agentik`, через `agentikAgent(agent, "/my")`)
|
||||||
|
|
||||||
|
| Метод | Путь | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/conversations` | Создать диалог (body: `{title?}`) |
|
||||||
|
| `GET` | `/conversations` | Список диалогов (по `?offset=&limit=`) |
|
||||||
|
| `GET` | `/conversations/:id` | Снимок диалога + count |
|
||||||
|
| `GET` | `/conversations/:id/messages` | История сообщений (по `?after=`) |
|
||||||
|
| `POST` | `/conversations/:id/rename` | Переименовать (body: `{title}`) |
|
||||||
|
| `DELETE` | `/conversations/:id` | Удалить |
|
||||||
|
| `POST` | `/conversations/:id/send` | Send-флоу (body: `{content:[…]}` → SSE) |
|
||||||
|
| `GET` | `/conversations/:id/events` | Live подписка (SSE) |
|
||||||
|
| `POST` | `/conversations/:id/interrupt` | Прервать текущий `send()` |
|
||||||
|
|
||||||
|
`Content-Type: text/event-stream` всегда для SSE, ноль-лишних
|
||||||
|
заголовков. Сообщения: `event: <name>` (`message`, `start_reasoning`,
|
||||||
|
`start_response`, `append_text`, `append_image`, `end`, `interrupted`,
|
||||||
|
`error`) + `data: <JSON>`.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :server:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: маппинг JSON ↔ Event, SSE framing, error-handling,
|
||||||
|
404 / 400 ответы, корректную обработку `Instant` в `kotlinx-datetime`.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого LLM-кода, tool-вызовов, прерываний. Только mapping Agent ↔ HTTP.
|
||||||
|
- Никакой БД, никакого storage. Это задача `Agent`-имплементации.
|
||||||
|
- Никакого CORS-конфига по умолчанию — добавляйте на свой engine.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Wire-контракт стабильный; новые Event'ы
|
||||||
|
добавляются только с snake_case-дискриминаторами и строго обратно
|
||||||
|
совместимо.
|
||||||
@@ -31,6 +31,7 @@ kotlin {
|
|||||||
|
|
||||||
// Commons
|
// Commons
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package pw.binom.agentik.server
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,3 +34,19 @@ internal data class RequestCreateConversation(val temp: Boolean)
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class RequestRename(val title: String)
|
internal data class RequestRename(val title: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тело `POST /conversations/{id}/messages`.
|
||||||
|
*
|
||||||
|
* Поддерживает два формата для backward-compat:
|
||||||
|
* - **новый**: `{"content": [...], "context": {...}}` — с контекстом инициации;
|
||||||
|
* - **старый**: голый JSON-массив `[...]` контента — context=null (обычный user).
|
||||||
|
*
|
||||||
|
* Маршрутизация формата делается в обработчике через try-{catch}-fallback на
|
||||||
|
* `List<Content>` десериализацию.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal data class RequestSendMessage(
|
||||||
|
val content: List<Content>,
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user